From 2e35746da3bf29ad1ba9e6e839c035d1a71e18d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:58:28 +0000 Subject: [PATCH 1/5] Add performance benchmark harness Covers write/read throughput, ID generation, broadcast latency as a function of observer count, and sorted-query rebroadcast cost as a function of result-set size. Lives under benchmark/ (excluded from the test suite and CI); run with `flutter test benchmark/loon_benchmark.dart`. --- benchmark/loon_benchmark.dart | 167 ++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 benchmark/loon_benchmark.dart diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart new file mode 100644 index 0000000..998a081 --- /dev/null +++ b/benchmark/loon_benchmark.dart @@ -0,0 +1,167 @@ +// ignore_for_file: avoid_print + +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 Loon.clearAll(); + Loon.unsubscribe(); + }); + + tearDownAll(() async { + await Loon.clearAll(); + Loon.unsubscribe(); + }); + + test('Write throughput (broadcast off)', () { + for (final n in [1000, 10000, 50000]) { + Loon.clearAll(); + 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 (Random.secure)', () { + for (final n in [10000, 100000]) { + final sw = Stopwatch()..start(); + for (var i = 0; i < n; i++) { + generateId(); + } + sw.stop(); + _report('generateId', n, sw.elapsedMicroseconds); + } + }); + + 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 Loon.clearAll(); + Loon.unsubscribe(); + + 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 Loon.clearAll(); + Loon.unsubscribe(); + + 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)}'); + } + }); +} + +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'); +} From 56a60b46113b9de87a9a2ab1da9a1e13a10aaece Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 11:58:35 +0000 Subject: [PATCH 2/5] perf: use non-crypto RNG for internal IDs Observer IDs and worker request-correlation IDs only need process-local uniqueness, but both were drawn from the OS CSPRNG via Random.secure(). Add generateInternalId backed by a plain Random and use it for those two call sites; document IDs keep the secure generator since apps may rely on their unguessability. Microbenchmark: ID generation drops from ~2.85us to ~0.25us/op (~11x). The end-to-end subscription win is smaller (~6%) since setup is dominated by stream-controller and store costs, but the change is free and removes needless CSPRNG overhead from per-subscription and per-message paths. --- benchmark/loon_benchmark.dart | 36 ++++++++++++++++++++++++++---- lib/broadcast_observer.dart | 2 +- lib/persistor/worker/messages.dart | 2 +- lib/utils/id.dart | 18 ++++++++++----- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart index 998a081..1ecb0e1 100644 --- a/benchmark/loon_benchmark.dart +++ b/benchmark/loon_benchmark.dart @@ -53,14 +53,42 @@ void main() { } }); - test('ID generation (Random.secure)', () { - for (final n in [10000, 100000]) { + test('ID generation', () { + const n = 100000; + for (final entry in { + 'generateId (secure)': generateId, + 'generateInternalId': generateInternalId, + }.entries) { + final gen = entry.value; final sw = Stopwatch()..start(); for (var i = 0; i < n; i++) { - generateId(); + gen(); } sw.stop(); - _report('generateId', n, sw.elapsedMicroseconds); + _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(); } }); diff --git a/lib/broadcast_observer.dart b/lib/broadcast_observer.dart index 8d7e549..b22a4db 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}__${generateInternalId()}"; Loon._instance.broadcastManager.addObserver(this, initialValue); } diff --git a/lib/persistor/worker/messages.dart b/lib/persistor/worker/messages.dart index 4fa5c39..85026c0 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 = generateInternalId(); MessageRequest(); diff --git a/lib/utils/id.dart b/lib/utils/id.dart index aab6a41..d3d1ff0 100644 --- a/lib/utils/id.dart +++ b/lib/utils/id.dart @@ -7,16 +7,15 @@ const String _alphabet = final Uint8List _alphabytes = Uint8List.fromList(_alphabet.codeUnits); const int _u32 = 0x100000000; // 2^32 -final Random _rand = Random.secure(); +final Random _secureRand = Random.secure(); +final Random _fastRand = Random(); -/// Generates a cryptographically secure, URL-safe random ID. -/// Default: 21 chars ≈ 126 bits of entropy. -String generateId([int size = 21]) { +String _generate(Random rand, 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. + int r = rand.nextInt(_u32); // A u32 can sample 5-characters (2^6)*5, 2 bits leftover. var k = 0; while (k < 5 && i < size) { @@ -29,3 +28,12 @@ String generateId([int size = 21]) { return String.fromCharCodes(out); } + +/// Generates a cryptographically secure, URL-safe random ID. +/// Default: 21 chars ≈ 126 bits of entropy. +String generateId([int size = 21]) => _generate(_secureRand, size); + +/// Generates a URL-safe random ID from a non-cryptographic PRNG, for internal +/// identifiers that only need process-local uniqueness (e.g. observer IDs). +/// Drawing from the OS CSPRNG via [generateId] is needless overhead there. +String generateInternalId([int size = 21]) => _generate(_fastRand, size); From fe98f3a433fbd992d16114bb10a7a5ce912a7c2d Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Sun, 31 May 2026 08:00:54 -0400 Subject: [PATCH 3/5] Address benchmark review feedback --- benchmark/loon_benchmark.dart | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart index 1ecb0e1..b7456fd 100644 --- a/benchmark/loon_benchmark.dart +++ b/benchmark/loon_benchmark.dart @@ -1,5 +1,7 @@ // 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'; @@ -13,18 +15,16 @@ import 'package:loon/utils/id.dart'; /// current machine and only meaningful relative to each other / across runs. void main() { setUp(() async { - await Loon.clearAll(); - Loon.unsubscribe(); + await _resetStore(); }); tearDownAll(() async { - await Loon.clearAll(); - Loon.unsubscribe(); + await _resetStore(); }); - test('Write throughput (broadcast off)', () { + test('Write throughput (broadcast off)', () async { for (final n in [1000, 10000, 50000]) { - Loon.clearAll(); + await _resetStore(); final col = Loon.collection('bench'); final sw = Stopwatch()..start(); for (var i = 0; i < n; i++) { @@ -79,7 +79,7 @@ void main() { col.doc('doc_$i').create(i, broadcast: false, persist: false); } - final subs = []; + final subs = ?>>[]; final sw = Stopwatch()..start(); for (var i = 0; i < n; i++) { subs.add(col.doc('doc_$i').observe().stream().listen((_) {})); @@ -103,8 +103,7 @@ void main() { print(' ${'N observers'.padRight(14)} ${'µs/broadcast'.padLeft(14)}'); for (final n in [0, 100, 1000, 5000]) { - await Loon.clearAll(); - Loon.unsubscribe(); + await _resetStore(); final col = Loon.collection('obs'); @@ -141,7 +140,8 @@ void main() { } final perBroadcast = sw.elapsedMicroseconds / rounds; - print(' ${n.toString().padRight(14)} ${perBroadcast.toStringAsFixed(1).padLeft(14)}'); + print( + ' ${n.toString().padRight(14)} ${perBroadcast.toStringAsFixed(1).padLeft(14)}'); } }); @@ -155,8 +155,7 @@ void main() { print(' ${'M docs'.padRight(14)} ${'µs/update'.padLeft(14)}'); for (final m in [100, 1000, 10000]) { - await Loon.clearAll(); - Loon.unsubscribe(); + await _resetStore(); final col = Loon.collection('q'); for (var i = 0; i < m; i++) { @@ -182,11 +181,17 @@ void main() { await sub.cancel(); final perUpdate = sw.elapsedMicroseconds / rounds; - print(' ${m.toString().padRight(14)} ${perUpdate.toStringAsFixed(1).padLeft(14)}'); + 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 ' From ff14004378e2d284c72eb6a5688e85d88ae50d64 Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Sun, 31 May 2026 08:14:05 -0400 Subject: [PATCH 4/5] Clarify ID generator names --- benchmark/loon_benchmark.dart | 4 ++-- lib/broadcast_observer.dart | 2 +- lib/collection.dart | 2 +- lib/persistor/worker/messages.dart | 2 +- lib/utils/id.dart | 22 ++++++++++++++-------- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart index b7456fd..97d8a01 100644 --- a/benchmark/loon_benchmark.dart +++ b/benchmark/loon_benchmark.dart @@ -56,8 +56,8 @@ void main() { test('ID generation', () { const n = 100000; for (final entry in { - 'generateId (secure)': generateId, - 'generateInternalId': generateInternalId, + 'generateSecureId': generateSecureId, + 'generateProcessLocalId': generateProcessLocalId, }.entries) { final gen = entry.value; final sw = Stopwatch()..start(); diff --git a/lib/broadcast_observer.dart b/lib/broadcast_observer.dart index b22a4db..c24d749 100644 --- a/lib/broadcast_observer.dart +++ b/lib/broadcast_observer.dart @@ -43,7 +43,7 @@ mixin BroadcastObserver { _controllerValue = initialValue; _controller.add(initialValue); - _observerId = "${path}__${generateInternalId()}"; + _observerId = "${path}__${generateProcessLocalId()}"; 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 85026c0..ac926f3 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 = generateInternalId(); + final id = generateProcessLocalId(); MessageRequest(); diff --git a/lib/utils/id.dart b/lib/utils/id.dart index d3d1ff0..4dc5c7a 100644 --- a/lib/utils/id.dart +++ b/lib/utils/id.dart @@ -7,15 +7,16 @@ const String _alphabet = final Uint8List _alphabytes = Uint8List.fromList(_alphabet.codeUnits); const int _u32 = 0x100000000; // 2^32 -final Random _secureRand = Random.secure(); -final Random _fastRand = Random(); +final Random _secureRandom = Random.secure(); +final Random _processLocalRandom = Random(); -String _generate(Random rand, int size) { +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) { @@ -31,9 +32,14 @@ String _generate(Random rand, int size) { /// Generates a cryptographically secure, URL-safe random ID. /// Default: 21 chars ≈ 126 bits of entropy. -String generateId([int size = 21]) => _generate(_secureRand, size); +String generateSecureId([int size = 21]) => + _generateRandomId(_secureRandom, size); + +/// Backward-compatible alias for [generateSecureId]. +String generateId([int size = 21]) => generateSecureId(size); /// Generates a URL-safe random ID from a non-cryptographic PRNG, for internal -/// identifiers that only need process-local uniqueness (e.g. observer IDs). -/// Drawing from the OS CSPRNG via [generateId] is needless overhead there. -String generateInternalId([int size = 21]) => _generate(_fastRand, size); +/// identifiers that only need process-local uniqueness. +/// Drawing from the OS CSPRNG via [generateSecureId] is needless overhead there. +String generateProcessLocalId([int size = 21]) => + _generateRandomId(_processLocalRandom, size); From b92b5bbf35a3b65eaf7d92f3951bc4bbc3a98609 Mon Sep 17 00:00:00 2001 From: Dan Reynolds Date: Sun, 31 May 2026 08:18:09 -0400 Subject: [PATCH 5/5] Use secure and fast ID utilities --- benchmark/loon_benchmark.dart | 2 +- example/lib/random_operation_runner.dart | 2 +- lib/broadcast_observer.dart | 2 +- lib/persistor/worker/messages.dart | 2 +- lib/utils/id.dart | 24 +++++++++++++----------- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/benchmark/loon_benchmark.dart b/benchmark/loon_benchmark.dart index 97d8a01..fe23afe 100644 --- a/benchmark/loon_benchmark.dart +++ b/benchmark/loon_benchmark.dart @@ -57,7 +57,7 @@ void main() { const n = 100000; for (final entry in { 'generateSecureId': generateSecureId, - 'generateProcessLocalId': generateProcessLocalId, + 'generateFastId': generateFastId, }.entries) { final gen = entry.value; final sw = Stopwatch()..start(); 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 c24d749..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}__${generateProcessLocalId()}"; + _observerId = "${path}__${generateFastId()}"; Loon._instance.broadcastManager.addObserver(this, initialValue); } diff --git a/lib/persistor/worker/messages.dart b/lib/persistor/worker/messages.dart index ac926f3..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 = generateProcessLocalId(); + final id = generateFastId(); MessageRequest(); diff --git a/lib/utils/id.dart b/lib/utils/id.dart index 4dc5c7a..16eb2ad 100644 --- a/lib/utils/id.dart +++ b/lib/utils/id.dart @@ -8,7 +8,7 @@ final Uint8List _alphabytes = Uint8List.fromList(_alphabet.codeUnits); const int _u32 = 0x100000000; // 2^32 final Random _secureRandom = Random.secure(); -final Random _processLocalRandom = Random(); +final Random _fastRandom = Random(); String _generateRandomId(Random random, int size) { final out = Uint8List(size); @@ -30,16 +30,18 @@ String _generateRandomId(Random random, int size) { return String.fromCharCodes(out); } -/// Generates a cryptographically secure, URL-safe random ID. -/// Default: 21 chars ≈ 126 bits of entropy. +/// 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); -/// Backward-compatible alias for [generateSecureId]. -String generateId([int size = 21]) => generateSecureId(size); - -/// Generates a URL-safe random ID from a non-cryptographic PRNG, for internal -/// identifiers that only need process-local uniqueness. -/// Drawing from the OS CSPRNG via [generateSecureId] is needless overhead there. -String generateProcessLocalId([int size = 21]) => - _generateRandomId(_processLocalRandom, 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);