diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart new file mode 100644 index 0000000..fe23afe --- /dev/null +++ b/benchmark/loon_benchmark.dart @@ -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(); + }); + + tearDownAll(() async { + await _resetStore(); + }); + + test('Write throughput (broadcast off)', () async { + for (final n in [1000, 10000, 50000]) { + await _resetStore(); + final col = Loon.collection('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('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('sub'); + for (var i = 0; i < n; i++) { + col.doc('doc_$i').create(i, broadcast: false, persist: false); + } + + final subs = ?>>[]; + 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('obs'); + + // The document we will repeatedly write to and await. + final target = col.doc('target'); + target.create(0, persist: false); + final emissions = []; + 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('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 _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'); +} diff --git a/example/lib/random_operation_runner.dart b/example/lib/random_operation_runner.dart index 7595e31..71e678a 100644 --- a/example/lib/random_operation_runner.dart +++ b/example/lib/random_operation_runner.dart @@ -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) { diff --git a/lib/broadcast_observer.dart b/lib/broadcast_observer.dart index 8d7e549..686438e 100644 --- a/lib/broadcast_observer.dart +++ b/lib/broadcast_observer.dart @@ -43,7 +43,7 @@ mixin BroadcastObserver { _controllerValue = initialValue; _controller.add(initialValue); - _observerId = "${path}__${generateId()}"; + _observerId = "${path}__${generateFastId()}"; Loon._instance.broadcastManager.addObserver(this, initialValue); } diff --git a/lib/collection.dart b/lib/collection.dart index a5c0633..a94be04 100644 --- a/lib/collection.dart +++ b/lib/collection.dart @@ -89,7 +89,7 @@ class Collection implements Queryable, StoreReference { Document doc([String? id]) { return Document( path, - id ?? generateId(), + id ?? generateSecureId(), fromJson: fromJson, toJson: toJson, persistorSettings: persistorSettings, diff --git a/lib/persistor/worker/messages.dart b/lib/persistor/worker/messages.dart index 4fa5c39..8a53367 100644 --- a/lib/persistor/worker/messages.dart +++ b/lib/persistor/worker/messages.dart @@ -5,7 +5,7 @@ import 'package:loon/utils/id.dart'; abstract class Message {} abstract class MessageRequest extends Message { - final id = generateId(); + final id = generateFastId(); MessageRequest(); diff --git a/lib/utils/id.dart b/lib/utils/id.dart index aab6a41..16eb2ad 100644 --- a/lib/utils/id.dart +++ b/lib/utils/id.dart @@ -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) { @@ -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);