From 291d8cbe9b908ced8f3e6698afa079ae2a00fe0a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 20 Aug 2026 12:34:48 +0200 Subject: [PATCH 1/5] perf(yet_another_json_isolate): process small payloads inline and large ones on short-lived isolates --- .../postgrest/lib/src/postgrest_builder.dart | 4 +- .../supabase/lib/src/supabase_client.dart | 5 +- .../lib/src/functions_client.dart | 5 +- packages/yet_another_json_isolate/README.md | 13 +- .../yet_another_json_isolate_benchmark.dart | 306 ++++++++++++++++++ .../lib/src/_isolates_io.dart | 229 ++++++------- .../lib/src/_isolates_web.dart | 15 + .../lib/yet_another_json_isolate.dart | 5 +- .../yet_another_json_isolate/pubspec.yaml | 5 +- .../test/yet_another_json_isolate_test.dart | 99 ++++++ sdk-compliance.yaml | 1 + 11 files changed, 546 insertions(+), 141 deletions(-) create mode 100644 packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 14a04efe1..f904811c7 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -411,8 +411,8 @@ class PostgrestBuilder implements Future { } else { try { final isolate = _isolate; - if ((response.contentLength ?? 0) > 10000 && isolate != null) { - body = await isolate.decode(response.body); + if (isolate != null) { + body = await isolate.decodeBytes(response.bodyBytes); } else { body = jsonDecode(response.body); } diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 949b20ec0..f6416f675 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -40,8 +40,9 @@ import 'trace_http_client.dart'; /// if this is not supported by the client libraries. When set, the `auth` /// namespace of the Supabase client cannot be used. /// -/// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted -/// isolate instance. A new instance will be created if [isolate] is omitted. +/// Pass an instance of `YAJsonIsolate` to [isolate] to share one instance +/// for JSON encoding and decoding across clients. A new instance will be +/// created if [isolate] is omitted. /// /// The pkce flow is used by default and keeps its code verifiers in the /// `AuthAsyncStorage` passed to the `pkceAsyncStorage` field of [authOptions]. diff --git a/packages/supabase_functions/lib/src/functions_client.dart b/packages/supabase_functions/lib/src/functions_client.dart index 71431775a..6dc54a8f4 100644 --- a/packages/supabase_functions/lib/src/functions_client.dart +++ b/packages/supabase_functions/lib/src/functions_client.dart @@ -220,17 +220,16 @@ class FunctionsClient { if (bodyBytes.isEmpty) { data = ""; } else { - final bodyText = utf8.decode(bodyBytes); dynamic decoded; try { - decoded = await _isolate.decode(bodyText); + decoded = await _isolate.decodeBytes(bodyBytes); } on FormatException { // A body labeled JSON that doesn't parse is only tolerated on an // error status, where the raw text still needs to reach the caller // as the exception `details`. On a success status it's a real // anomaly, so keep surfacing it instead of handing back a String. if (isSuccessStatus) rethrow; - decoded = bodyText; + decoded = utf8.decode(bodyBytes); } data = decoded; } diff --git a/packages/yet_another_json_isolate/README.md b/packages/yet_another_json_isolate/README.md index 1ab2c6554..d1981a975 100644 --- a/packages/yet_another_json_isolate/README.md +++ b/packages/yet_another_json_isolate/README.md @@ -7,7 +7,7 @@

yet_another_json_isolate

- Simplify and improve JSON parsing in isolates by keeping one isolate running per instance. + JSON parsing that never blocks the main isolate for long: small payloads are processed inline, large payloads on short lived isolates that hand their result back without copying.

@@ -21,15 +21,18 @@ ## Usage ```dart -// initialize an `YAJsonIsolate` instance -final isolate = YAJsonIsolate()..initialize(); +final isolate = YAJsonIsolate(); -// serialize a JSON using an isolate +// serialize to a JSON string final requestBody = await isolate.encode(requestObject); -// deserialize a JSON string using an isolate +// deserialize a JSON string final json = await isolate.decode(responseBody); +// deserialize UTF-8 encoded JSON bytes, such as an HTTP response body, +// without materializing the intermediate string on the calling isolate +final data = await isolate.decodeBytes(response.bodyBytes); + // dispose when no longer needed isolate.dispose(); ``` diff --git a/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart b/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart new file mode 100644 index 000000000..c4e555379 --- /dev/null +++ b/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart @@ -0,0 +1,306 @@ +/// Benchmarks [YAJsonIsolate] with payload shapes similar to Supabase API +/// responses: lists of row objects at various sizes, plus concurrent load. +/// +/// Reports per-operation latency and the longest main-isolate event-loop +/// stall observed during each scenario, so that improvements in throughput +/// that come at the cost of blocking the main isolate are visible. +/// +/// Run with: dart run benchmark/yet_another_json_isolate_benchmark.dart +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; + +Future main() async { + stdout.writeln('Dart ${Platform.version}'); + stdout.writeln(''); + + final singleRow = jsonEncode(_buildRow(0)); + final kilobytes2 = _buildJsonListOfApproximateSize(2 * 1024); + final kilobytes50 = _buildJsonListOfApproximateSize(50 * 1024); + final megabytes1 = _buildJsonListOfApproximateSize(1024 * 1024); + final megabytes5 = _buildJsonListOfApproximateSize(5 * 1024 * 1024); + + final results = <_ScenarioResult>[]; + + for (final scenario in [ + _decodeScenario( + 'decode single row (${_formatSize(singleRow.length)})', + singleRow, + 400, + ), + _decodeScenario( + 'decode ${_formatSize(kilobytes2.length)}', + kilobytes2, + 400, + ), + _decodeScenario( + 'decode ${_formatSize(kilobytes50.length)}', + kilobytes50, + 200, + ), + _decodeScenario('decode ${_formatSize(megabytes1.length)}', megabytes1, 40), + _decodeScenario('decode ${_formatSize(megabytes5.length)}', megabytes5, 10), + _encodeScenario( + 'encode single row (${_formatSize(singleRow.length)})', + singleRow, + 400, + ), + _encodeScenario( + 'encode ${_formatSize(kilobytes2.length)}', + kilobytes2, + 400, + ), + _encodeScenario( + 'encode ${_formatSize(kilobytes50.length)}', + kilobytes50, + 200, + ), + _encodeScenario('encode ${_formatSize(megabytes1.length)}', megabytes1, 40), + _decodeBytesScenario( + 'decodeBytes ${_formatSize(kilobytes50.length)}', + kilobytes50, + 200, + ), + _decodeBytesScenario( + 'decodeBytes ${_formatSize(megabytes1.length)}', + megabytes1, + 40, + ), + _decodeBytesScenario( + 'decodeBytes ${_formatSize(megabytes5.length)}', + megabytes5, + 10, + ), + _concurrentDecodeScenario( + 'decode ${_formatSize(kilobytes50.length)} x8 concurrent', + kilobytes50, + 8, + 50, + ), + _concurrentDecodeScenario( + 'decode ${_formatSize(megabytes1.length)} x4 concurrent', + megabytes1, + 4, + 15, + ), + ]) { + results.add(await _runScenario(scenario)); + } + + _printTable(results); +} + +class _Scenario { + _Scenario({ + required this.name, + required this.iterations, + required this.body, + }); + + final String name; + final int iterations; + final Future Function(YAJsonIsolate isolate) body; +} + +class _ScenarioResult { + _ScenarioResult({ + required this.name, + required this.latenciesMicroseconds, + required this.maxStallMicroseconds, + }); + + final String name; + final List latenciesMicroseconds; + final int maxStallMicroseconds; + + int get median => _percentile(0.5); + int get percentile90 => _percentile(0.9); + int get maximum => latenciesMicroseconds.last; + + int _percentile(double fraction) { + final index = ((latenciesMicroseconds.length - 1) * fraction).round(); + return latenciesMicroseconds[index]; + } +} + +_Scenario _decodeScenario(String name, String json, int iterations) { + return _Scenario( + name: name, + iterations: iterations, + body: (isolate) => isolate.decode(json), + ); +} + +_Scenario _decodeBytesScenario(String name, String json, int iterations) { + final bytes = utf8.encode(json); + return _Scenario( + name: name, + iterations: iterations, + body: (isolate) => isolate.decodeBytes(bytes), + ); +} + +_Scenario _encodeScenario(String name, String json, int iterations) { + final value = jsonDecode(json); + return _Scenario( + name: name, + iterations: iterations, + body: (isolate) => isolate.encode(value), + ); +} + +_Scenario _concurrentDecodeScenario( + String name, + String json, + int concurrency, + int iterations, +) { + return _Scenario( + name: name, + iterations: iterations, + body: (isolate) => Future.wait( + [for (var i = 0; i < concurrency; i++) isolate.decode(json)], + ), + ); +} + +Future<_ScenarioResult> _runScenario(_Scenario scenario) async { + final isolate = YAJsonIsolate(debugName: 'benchmark'); + await isolate.initialize(); + + final warmupIterations = (scenario.iterations ~/ 10).clamp(2, 20); + for (var i = 0; i < warmupIterations; i++) { + await scenario.body(isolate); + } + + final latencies = []; + final stallMonitor = _EventLoopStallMonitor()..start(); + final stopwatch = Stopwatch(); + for (var i = 0; i < scenario.iterations; i++) { + stopwatch + ..reset() + ..start(); + await scenario.body(isolate); + stopwatch.stop(); + latencies.add(stopwatch.elapsedMicroseconds); + // Yields to the event queue so the stall monitor's timer gets a chance + // to fire between operations; awaiting only the operation would starve + // it and hide stalls caused by synchronous work. + await Future.delayed(Duration.zero); + } + final maxStall = stallMonitor.stop(); + await isolate.dispose(); + + latencies.sort(); + stdout.writeln('finished: ${scenario.name}'); + return _ScenarioResult( + name: scenario.name, + latenciesMicroseconds: latencies, + maxStallMicroseconds: maxStall, + ); +} + +/// Measures gaps between 1ms periodic timer ticks on the main isolate. +/// +/// A gap far above 1ms means the main isolate was blocked and would have +/// dropped frames in a Flutter application. +class _EventLoopStallMonitor { + final Stopwatch _stopwatch = Stopwatch(); + Timer? _timer; + int _previousTickMicroseconds = 0; + int _maxGapMicroseconds = 0; + + void start() { + _stopwatch + ..reset() + ..start(); + _previousTickMicroseconds = 0; + _maxGapMicroseconds = 0; + _timer = Timer.periodic(const Duration(milliseconds: 1), (_) { + final now = _stopwatch.elapsedMicroseconds; + final gap = now - _previousTickMicroseconds; + if (gap > _maxGapMicroseconds) { + _maxGapMicroseconds = gap; + } + _previousTickMicroseconds = now; + }); + } + + /// Stops the monitor and returns the longest stall in microseconds, with + /// the expected 1ms tick interval subtracted. + int stop() { + _timer?.cancel(); + _stopwatch.stop(); + final stall = _maxGapMicroseconds - 1000; + return stall < 0 ? 0 : stall; + } +} + +Map _buildRow(int index) { + return { + 'id': index, + 'uuid': '00000000-0000-4000-8000-${index.toString().padLeft(12, '0')}', + 'created_at': '2026-08-20T09:00:00.000Z', + 'name': 'user_$index', + 'email': 'user_$index@example.com', + 'is_active': index.isEven, + 'score': index * 1.5, + 'tags': ['alpha', 'beta', 'gamma'], + 'metadata': { + 'source': 'benchmark', + 'index': index, + 'nested': {'depth': 2, 'flag': true}, + }, + 'description': + 'Row $index with a description long enough to resemble real user ' + 'generated content stored in a text column of a Supabase table.', + }; +} + +String _buildJsonListOfApproximateSize(int targetBytes) { + final rows = >[]; + var encodedLength = 2; + var index = 0; + while (encodedLength < targetBytes) { + final row = _buildRow(index++); + encodedLength += jsonEncode(row).length + 1; + rows.add(row); + } + return jsonEncode(rows); +} + +String _formatSize(int bytes) { + if (bytes >= 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + if (bytes >= 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '$bytes B'; +} + +void _printTable(List<_ScenarioResult> results) { + const nameWidth = 34; + const columnWidth = 12; + stdout.writeln(''); + stdout.writeln( + '${'scenario'.padRight(nameWidth)}' + '${'p50 µs'.padLeft(columnWidth)}' + '${'p90 µs'.padLeft(columnWidth)}' + '${'max µs'.padLeft(columnWidth)}' + '${'stall µs'.padLeft(columnWidth)}', + ); + for (final result in results) { + stdout.writeln( + '${result.name.padRight(nameWidth)}' + '${result.median.toString().padLeft(columnWidth)}' + '${result.percentile90.toString().padLeft(columnWidth)}' + '${result.maximum.toString().padLeft(columnWidth)}' + '${result.maxStallMicroseconds.toString().padLeft(columnWidth)}', + ); + } +} diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index 43c2d486a..bd10f4217 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -1,24 +1,39 @@ -//Modified from https://github.com/dart-lang/samples/blob/master/isolates/bin/long_running_isolate.dart - import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; +import 'dart:typed_data'; + +/// Payloads estimated to be smaller than this are processed directly on the +/// calling isolate. +/// +/// Below this size the JSON work takes well under a millisecond even on slow +/// devices, while handing it to another isolate costs more than that in +/// messaging overhead. +const _isolateThresholdBytes = 64 * 1024; -import 'package:async/async.dart'; +/// Depth guard for [_remainingBudget], so that a cyclic or extremely deep +/// structure is handed to the isolate path instead of overflowing the stack +/// during estimation. +const _maxEstimationDepth = 512; -// One instance manages one isolate +final Converter, Object?> _utf8JsonDecoder = const Utf8Decoder().fuse( + const JsonDecoder(), +); + +/// Encodes and decodes JSON without blocking the calling isolate. +/// +/// Small payloads are processed inline, because parsing them costs less than +/// an isolate round trip. Large payloads are processed on a short lived +/// isolate spawned per call: its result is handed back through `Isolate.exit` +/// without copying, and independent calls run in parallel. class YAJsonIsolate { YAJsonIsolate({ this.debugName, }); - /// The debug name used for the isolate spawned by this instance. + /// The debug name used for the isolates spawned by this instance. final String? debugName; - final _receivePort = ReceivePort(); - late final SendPort _sendPort; - final _createdIsolate = Completer(); - late final _events = StreamQueue(_receivePort); bool _hasStartedInitialize = false; Future? _disposal; @@ -30,11 +45,11 @@ class YAJsonIsolate { } } - /// Initialize the isolate + /// Kept for backwards compatibility. /// - /// This method is called automatically when the first method is called. - /// Manually initializing before the first JSON decode or encode can improve - /// performance. + /// There is no persistent isolate anymore, so there is nothing to + /// initialize: large payloads are processed on short lived isolates spawned + /// per call. Future initialize() async { _throwIfDisposed(); assert( @@ -42,134 +57,102 @@ class YAJsonIsolate { 'initialize() can only be called once per isolate.', ); _hasStartedInitialize = true; - await Isolate.spawn( - _compute, - _receivePort.sendPort, - onExit: _receivePort.sendPort, - onError: _receivePort.sendPort, - debugName: debugName, - ); - _sendPort = await _events.next; - _createdIsolate.complete(); } - /// Dispose the isolate + /// Dispose the instance. /// - /// This exits the isolate. Safe to call more than once, and safe to call on - /// an instance that was never used. Concurrent calls all await the same - /// shutdown, so awaiting any of them means the isolate is gone. Using the + /// Safe to call more than once, and safe to call on an instance that was + /// never used. Concurrent calls all await the same disposal. Using the /// instance afterwards throws a [StateError]. - Future dispose() => _disposal ??= _dispose(); - - Future _dispose() async { - if (!_hasStartedInitialize) { - _receivePort.close(); - return; - } - - await _createdIsolate.future; - _sendPort.send(null); - _receivePort.close(); - await _events.cancel(); - } + Future dispose() => _disposal ??= Future.value(); + /// Decodes [json] into Dart values, like [jsonDecode]. + /// + /// Small payloads are decoded inline, large ones on a short lived isolate. Future decode(String json) async { _throwIfDisposed(); - if (!_createdIsolate.isCompleted) { - if (!_hasStartedInitialize) await initialize(); - await _createdIsolate.future; + if (json.length < _isolateThresholdBytes) { + await null; + return jsonDecode(json); } - _sendPort.send([json, false]); - return _handleResponse(await _events.next); + return Isolate.run(() => jsonDecode(json), debugName: debugName); } - Future encode(Object? json) async { + /// Decodes UTF-8 encoded JSON in [encodedJson] into Dart values. + /// + /// Preferred over [decode] when the payload is available as bytes, such as + /// an HTTP response body: the bytes are moved to the decoding isolate + /// without copying and the UTF-8 and JSON decoding steps are fused, so the + /// calling isolate never pays for materializing the intermediate string. + Future decodeBytes(Uint8List encodedJson) async { _throwIfDisposed(); - if (!_createdIsolate.isCompleted) { - if (!_hasStartedInitialize) await initialize(); - await _createdIsolate.future; + if (encodedJson.length < _isolateThresholdBytes) { + await null; + return _utf8JsonDecoder.convert(encodedJson); } - _sendPort.send([json, true]); - return _handleResponse(await _events.next); - } - - Future _handleResponse(List response) async { - final int type = response.length; - assert(1 <= type && type <= 3); - - switch (type) { - // success; see _buildSuccessResponse - case 1: - return response[0] as R; - - // native error; see Isolate.addErrorListener - case 2: - await Future.error( - RemoteError( - response[0] as String, - response[1] as String, - ), - ); - - // caught error; see _buildErrorResponse - case 3: - default: - assert(type == 3 && response[2] == null); - - await Future.error( - response[0] as Object, - response[1] as StackTrace, - ); - } - } -} - -List _computeResponse(dynamic input, {required bool isEncoding}) { - try { - return _buildSuccessResponse( - isEncoding ? jsonEncode(input) : jsonDecode(input), + final transferable = TransferableTypedData.fromList([encodedJson]); + return Isolate.run( + () => _utf8JsonDecoder.convert(transferable.materialize().asUint8List()), + debugName: debugName, ); - } catch (error, stackTrace) { - return _buildErrorResponse(error, stackTrace); } -} - -void _compute(SendPort sendPort) async { - final commandPort = ReceivePort(); - sendPort.send(commandPort.sendPort); - - await for (final event in commandPort) { - // [event] is a list of [input, isEncoding] - if (event is List) { - final input = event.first; - /// `true` for encoding and `false` for decoding - final bool isEncoding = event.last; - - sendPort.send(_computeResponse(input, isEncoding: isEncoding)); - } else if (event == null) { - break; + /// Encodes [json] into a JSON string, like [jsonEncode]. + /// + /// Payloads estimated to be small are encoded inline, the rest on a short + /// lived isolate. + Future encode(Object? json) async { + _throwIfDisposed(); + if (_remainingBudget(json, _isolateThresholdBytes, 0) >= 0) { + await null; + return jsonEncode(json); } + return Isolate.run(() => jsonEncode(json), debugName: debugName); } - Isolate.exit(); } -/// Wrap in [List] to ensure our expectations in the main [Isolate] are met. +/// Returns what is left of [budget] after subtracting an estimate of the +/// encoded size of [value], or a negative number as soon as the estimate +/// exceeds the budget. /// -/// We need to wrap a success result in a [List] because the user provided type -/// [R] could also be a [List]. Meaning, a check `result is R` could return true -/// for what was an error event. -List _buildSuccessResponse(R result) { - return List.filled(1, result); -} - -/// Wrap in [List] to ensure our expectations in the main isolate are met. -/// -/// We wrap a caught error in a 3 element [List]. Where the last element is -/// always null. We do this so we have a way to know if an error was one we -/// caught or one thrown by the library code. -List _buildErrorResponse(Object error, StackTrace stackTrace) { - return List.filled(3, null) - ..[0] = error - ..[1] = stackTrace; +/// The estimate is deliberately rough: it only has to decide whether encoding +/// inline could block the calling isolate for too long, and it must cost far +/// less than the encoding itself. Values of unrecognized types, and structures +/// nested deeper than [_maxEstimationDepth], exhaust the budget immediately so +/// they are encoded on an isolate. +int _remainingBudget(Object? value, int budget, int depth) { + if (budget < 0 || depth > _maxEstimationDepth) { + return -1; + } + switch (value) { + case null: + return budget - 4; + case bool _: + return budget - 5; + case num _: + return budget - 8; + case String string: + return budget - string.length - 2; + case List list: + budget -= 2; + for (final element in list) { + budget = _remainingBudget(element, budget, depth + 1) - 1; + if (budget < 0) { + return -1; + } + } + return budget; + case Map map: + budget -= 2; + for (final entry in map.entries) { + budget = _remainingBudget(entry.key, budget, depth + 1); + budget = _remainingBudget(entry.value, budget, depth + 1) - 2; + if (budget < 0) { + return -1; + } + } + return budget; + default: + return -1; + } } diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_web.dart b/packages/yet_another_json_isolate/lib/src/_isolates_web.dart index a6ba23d74..ee7a829fa 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_web.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_web.dart @@ -1,5 +1,15 @@ import 'dart:convert'; +import 'dart:typed_data'; +final Converter, Object?> _utf8JsonDecoder = const Utf8Decoder().fuse( + const JsonDecoder(), +); + +/// Web variant of [YAJsonIsolate]. +/// +/// The web platform has no isolates, so all work happens inline. Decoding +/// from bytes still fuses the UTF-8 and JSON decoding steps, which avoids +/// materializing the intermediate string. class YAJsonIsolate { const YAJsonIsolate({ String? debugName, @@ -14,6 +24,11 @@ class YAJsonIsolate { return jsonDecode(json); } + Future decodeBytes(Uint8List encodedJson) async { + await null; + return _utf8JsonDecoder.convert(encodedJson); + } + Future encode(Object? json) async { await null; return jsonEncode(json); diff --git a/packages/yet_another_json_isolate/lib/yet_another_json_isolate.dart b/packages/yet_another_json_isolate/lib/yet_another_json_isolate.dart index 0cd4cd492..8efe0e93b 100644 --- a/packages/yet_another_json_isolate/lib/yet_another_json_isolate.dart +++ b/packages/yet_another_json_isolate/lib/yet_another_json_isolate.dart @@ -1,5 +1,6 @@ -/// Simplifies JSON parsing in isolates by keeping one isolate running per -/// instance. +/// JSON encoding and decoding that never blocks the calling isolate for +/// long: small payloads are processed inline, large payloads on short lived +/// isolates that hand their result back without copying. library; export 'src/_isolates_io.dart' diff --git a/packages/yet_another_json_isolate/pubspec.yaml b/packages/yet_another_json_isolate/pubspec.yaml index b75c1c92d..cca86f32d 100644 --- a/packages/yet_another_json_isolate/pubspec.yaml +++ b/packages/yet_another_json_isolate/pubspec.yaml @@ -1,5 +1,5 @@ name: yet_another_json_isolate -description: Package to simplify and improve JSON parsing in isolates by keeping one isolate running per instance. +description: Package for JSON parsing off the main isolate, processing small payloads inline and large payloads on short lived isolates without copying results. version: 2.1.1 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/yet_another_json_isolate' @@ -15,9 +15,6 @@ environment: resolution: workspace -dependencies: - async: ^2.12.0 - dev_dependencies: supabase_lints: ^0.1.1 test: ^1.25.0 diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart index d0945c541..8f23d7b9f 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:test/test.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -6,6 +7,17 @@ import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; const _jsonString = '{"a":1,"b":2}'; const _jsonMap = {'a': 1, 'b': 2}; +/// Large enough to encode to well over 64 KiB, so that operations on it are +/// processed on an isolate rather than inline. +final _largeValue = List.generate( + 2000, + (index) => { + 'id': index, + 'name': 'user_$index', + 'description': 'A description for row $index. ${'x' * 40}', + }, +); + void main() { late YAJsonIsolate isolate; @@ -106,6 +118,93 @@ void main() { }); }); + group('decodeBytes', () { + setUp(() { + isolate = YAJsonIsolate(); + }); + + tearDown(() async { + await isolate.dispose(); + }); + + test('decodes UTF-8 encoded JSON bytes', () async { + expect(await isolate.decodeBytes(utf8.encode(_jsonString)), _jsonMap); + }); + + test('preserves unicode characters', () async { + const value = {'emoji': '🚀', 'text': 'Grüße'}; + expect( + await isolate.decodeBytes(utf8.encode(jsonEncode(value))), + value, + ); + }); + + test('throws FormatException for invalid JSON bytes', () async { + await expectLater( + isolate.decodeBytes(utf8.encode('{not valid json')), + throwsFormatException, + ); + }); + + test('throws FormatException for invalid UTF-8 bytes', () async { + await expectLater( + isolate.decodeBytes(Uint8List.fromList([0x22, 0xFF, 0xFE, 0x22])), + throwsFormatException, + ); + }); + }); + + group('large payloads', () { + setUp(() { + isolate = YAJsonIsolate(); + }); + + tearDown(() async { + await isolate.dispose(); + }); + + test('round trips a large structure', () async { + final encoded = await isolate.encode(_largeValue); + expect(encoded.length, greaterThan(64 * 1024)); + expect(await isolate.decode(encoded), _largeValue); + }); + + test('decodes large UTF-8 encoded JSON bytes', () async { + final encoded = utf8.encode(jsonEncode(_largeValue)); + expect(await isolate.decodeBytes(encoded), _largeValue); + }); + + test('throws FormatException for large invalid JSON', () async { + final truncated = jsonEncode( + _largeValue, + ).substring(0, 70 * 1024); + await expectLater(isolate.decode(truncated), throwsFormatException); + await expectLater( + isolate.decodeBytes(utf8.encode(truncated)), + throwsFormatException, + ); + }); + + test('throws for a large structure with a non encodable value', () async { + final value = [..._largeValue, DateTime.now()]; + await expectLater( + isolate.encode(value), + throwsA(isA()), + ); + }); + + test('resolves concurrent large decodes to the correct results', () async { + final first = jsonEncode(_largeValue); + final second = jsonEncode(_largeValue.reversed.toList()); + final results = await Future.wait([ + isolate.decode(first), + isolate.decode(second), + ]); + expect(results[0], _largeValue); + expect(results[1], _largeValue.reversed.toList()); + }); + }); + group('error handling', () { setUp(() { isolate = YAJsonIsolate(); diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 472b4c23f..734d2a4d5 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -2617,6 +2617,7 @@ supporting_symbols: - YAJsonIsolate.YAJsonIsolate - YAJsonIsolate.debugName - YAJsonIsolate.decode + - YAJsonIsolate.decodeBytes - YAJsonIsolate.dispose - YAJsonIsolate.encode - YAJsonIsolate.initialize From 5d0739f0a9e3295ddec0f83b4140d01b8bc7232c Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 20 Aug 2026 12:42:20 +0200 Subject: [PATCH 2/5] chore: fix the DCM warnings in the benchmark and initialize --- .../benchmark/yet_another_json_isolate_benchmark.dart | 4 ++-- packages/yet_another_json_isolate/lib/src/_isolates_io.dart | 3 ++- .../test/yet_another_json_isolate_io_test.dart | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart b/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart index c4e555379..660b16629 100644 --- a/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart +++ b/packages/yet_another_json_isolate/benchmark/yet_another_json_isolate_benchmark.dart @@ -95,7 +95,7 @@ Future main() async { } class _Scenario { - _Scenario({ + const _Scenario({ required this.name, required this.iterations, required this.body, @@ -107,7 +107,7 @@ class _Scenario { } class _ScenarioResult { - _ScenarioResult({ + const _ScenarioResult({ required this.name, required this.latenciesMicroseconds, required this.maxStallMicroseconds, diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index bd10f4217..4918eee2d 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -50,13 +50,14 @@ class YAJsonIsolate { /// There is no persistent isolate anymore, so there is nothing to /// initialize: large payloads are processed on short lived isolates spawned /// per call. - Future initialize() async { + Future initialize() { _throwIfDisposed(); assert( _hasStartedInitialize == false, 'initialize() can only be called once per isolate.', ); _hasStartedInitialize = true; + return Future.value(); } /// Dispose the instance. diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart index f2c34f9da..5a5599e0e 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart @@ -15,7 +15,7 @@ void main() { final isolate = YAJsonIsolate(); await isolate.initialize(); addTearDown(() => dispose(isolate)); - expect(isolate.initialize(), throwsA(isA())); + expect(() => isolate.initialize(), throwsA(isA())); }); test('exposes the provided debug name', () { @@ -56,7 +56,7 @@ void main() { expect(isolate.decode('{}'), throwsStateError); expect(isolate.encode({}), throwsStateError); - expect(isolate.initialize(), throwsStateError); + expect(() => isolate.initialize(), throwsStateError); }); test('a never used isolate also rejects work after dispose', () async { From 37197249807865591b30e12db9d5e3d50b6cee69 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 20 Aug 2026 14:11:01 +0200 Subject: [PATCH 3/5] fix: address the review comments on decoding charset, encode fallback, and initialize errors --- .../postgrest/lib/src/postgrest_builder.dart | 2 +- .../lib/src/_isolates_io.dart | 32 +++++++++------ .../yet_another_json_isolate_io_test.dart | 40 ++++++++++++++++++- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index f904811c7..c41a550eb 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -414,7 +414,7 @@ class PostgrestBuilder implements Future { if (isolate != null) { body = await isolate.decodeBytes(response.bodyBytes); } else { - body = jsonDecode(response.body); + body = jsonDecode(utf8.decode(response.bodyBytes)); } } on FormatException catch (_) { // A 2xx status does not guarantee a JSON body. A proxy or gateway diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index 4918eee2d..c5616f477 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -51,13 +51,14 @@ class YAJsonIsolate { /// initialize: large payloads are processed on short lived isolates spawned /// per call. Future initialize() { - _throwIfDisposed(); - assert( - _hasStartedInitialize == false, - 'initialize() can only be called once per isolate.', - ); - _hasStartedInitialize = true; - return Future.value(); + return Future.sync(() { + _throwIfDisposed(); + assert( + _hasStartedInitialize == false, + 'initialize() can only be called once per isolate.', + ); + _hasStartedInitialize = true; + }); } /// Dispose the instance. @@ -82,9 +83,10 @@ class YAJsonIsolate { /// Decodes UTF-8 encoded JSON in [encodedJson] into Dart values. /// /// Preferred over [decode] when the payload is available as bytes, such as - /// an HTTP response body: the bytes are moved to the decoding isolate - /// without copying and the UTF-8 and JSON decoding steps are fused, so the - /// calling isolate never pays for materializing the intermediate string. + /// an HTTP response body: the bytes are copied once into a buffer that is + /// handed to the decoding isolate in constant time, and the UTF-8 and JSON + /// decoding steps are fused, so the calling isolate never pays for + /// materializing the intermediate string. Future decodeBytes(Uint8List encodedJson) async { _throwIfDisposed(); if (encodedJson.length < _isolateThresholdBytes) { @@ -101,14 +103,20 @@ class YAJsonIsolate { /// Encodes [json] into a JSON string, like [jsonEncode]. /// /// Payloads estimated to be small are encoded inline, the rest on a short - /// lived isolate. + /// lived isolate. A value that cannot be sent to an isolate, for example an + /// object whose `toJson()` result is encodable while the object itself + /// holds a `ReceivePort`, is encoded inline instead. Future encode(Object? json) async { _throwIfDisposed(); if (_remainingBudget(json, _isolateThresholdBytes, 0) >= 0) { await null; return jsonEncode(json); } - return Isolate.run(() => jsonEncode(json), debugName: debugName); + try { + return await Isolate.run(() => jsonEncode(json), debugName: debugName); + } on ArgumentError { + return jsonEncode(json); + } } } diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart index 5a5599e0e..307c4533b 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart @@ -1,9 +1,19 @@ @TestOn('vm') library; +import 'dart:isolate'; + import 'package:test/test.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; +/// JSON encodable through [toJson], but not sendable to another isolate +/// because it holds a [ReceivePort]. +class _UnsendableButEncodable { + final ReceivePort port = ReceivePort(); + + Map toJson() => {'type': 'unsendable'}; +} + /// Every disposal in this group is bounded, so a regression that makes /// `dispose()` wait forever fails the test instead of hanging the suite. Future dispose(YAJsonIsolate isolate) => @@ -15,7 +25,7 @@ void main() { final isolate = YAJsonIsolate(); await isolate.initialize(); addTearDown(() => dispose(isolate)); - expect(() => isolate.initialize(), throwsA(isA())); + expect(isolate.initialize(), throwsA(isA())); }); test('exposes the provided debug name', () { @@ -56,7 +66,7 @@ void main() { expect(isolate.decode('{}'), throwsStateError); expect(isolate.encode({}), throwsStateError); - expect(() => isolate.initialize(), throwsStateError); + expect(isolate.initialize(), throwsStateError); }); test('a never used isolate also rejects work after dispose', () async { @@ -65,5 +75,31 @@ void main() { expect(isolate.decode('{}'), throwsStateError); }); + + test('encodes an unsendable value inline', () async { + final isolate = YAJsonIsolate(); + addTearDown(() => dispose(isolate)); + final value = _UnsendableButEncodable(); + addTearDown(value.port.close); + + expect(await isolate.encode(value), '{"type":"unsendable"}'); + }); + + test( + 'encodes a large structure holding an unsendable value inline', + () async { + final isolate = YAJsonIsolate(); + addTearDown(() => dispose(isolate)); + final value = _UnsendableButEncodable(); + addTearDown(value.port.close); + final large = [ + for (var i = 0; i < 5000; i++) {'id': i, 'name': 'user_$i'}, + value, + ]; + + final encoded = await isolate.encode(large); + expect(encoded, endsWith('{"type":"unsendable"}]')); + }, + ); }); } From 8a639ae67208806d7e9bcb286f1e2a5c52d4313b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 20 Aug 2026 14:17:40 +0200 Subject: [PATCH 4/5] fix: make dispose await in-flight isolate work and charge numbers conservatively in the encode estimate --- .../lib/src/_isolates_io.dart | 41 +++++++++++++++---- .../yet_another_json_isolate_io_test.dart | 22 ++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index c5616f477..5e6ac8d6f 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -36,6 +36,7 @@ class YAJsonIsolate { bool _hasStartedInitialize = false; Future? _disposal; + final Set> _activeWork = {}; bool get _isDisposed => _disposal != null; @@ -63,10 +64,26 @@ class YAJsonIsolate { /// Dispose the instance. /// - /// Safe to call more than once, and safe to call on an instance that was - /// never used. Concurrent calls all await the same disposal. Using the - /// instance afterwards throws a [StateError]. - Future dispose() => _disposal ??= Future.value(); + /// Rejects new work immediately and completes once the isolate work that + /// was still in flight has finished. Safe to call more than once, and safe + /// to call on an instance that was never used. Concurrent calls all await + /// the same disposal. Using the instance afterwards throws a [StateError]. + Future dispose() => _disposal ??= _activeWork.isEmpty + ? Future.value() + : Future.wait(_activeWork.toList()).then((_) {}); + + /// Runs [computation] on a short lived isolate, keeping the call tracked + /// so [dispose] can await it. + Future _runTracked(T Function() computation) async { + final completer = Completer(); + _activeWork.add(completer.future); + try { + return await Isolate.run(computation, debugName: debugName); + } finally { + _activeWork.remove(completer.future); + completer.complete(); + } + } /// Decodes [json] into Dart values, like [jsonDecode]. /// @@ -77,7 +94,7 @@ class YAJsonIsolate { await null; return jsonDecode(json); } - return Isolate.run(() => jsonDecode(json), debugName: debugName); + return _runTracked(() => jsonDecode(json)); } /// Decodes UTF-8 encoded JSON in [encodedJson] into Dart values. @@ -94,9 +111,8 @@ class YAJsonIsolate { return _utf8JsonDecoder.convert(encodedJson); } final transferable = TransferableTypedData.fromList([encodedJson]); - return Isolate.run( + return _runTracked( () => _utf8JsonDecoder.convert(transferable.materialize().asUint8List()), - debugName: debugName, ); } @@ -113,7 +129,7 @@ class YAJsonIsolate { return jsonEncode(json); } try { - return await Isolate.run(() => jsonEncode(json), debugName: debugName); + return await _runTracked(() => jsonEncode(json)); } on ArgumentError { return jsonEncode(json); } @@ -129,6 +145,13 @@ class YAJsonIsolate { /// less than the encoding itself. Values of unrecognized types, and structures /// nested deeper than [_maxEstimationDepth], exhaust the budget immediately so /// they are encoded on an isolate. +/// +/// Numbers are charged at their maximum textual width. Strings are charged +/// one character each, without inspecting them for characters that JSON +/// escaping expands, because such a scan would cost nearly as much as the +/// encoding itself. A string dense in escaped characters can therefore be +/// undercounted by up to a factor of six, which at worst lets a payload a few +/// times [_isolateThresholdBytes] be encoded inline. int _remainingBudget(Object? value, int budget, int depth) { if (budget < 0 || depth > _maxEstimationDepth) { return -1; @@ -139,7 +162,7 @@ int _remainingBudget(Object? value, int budget, int depth) { case bool _: return budget - 5; case num _: - return budget - 8; + return budget - 20; case String string: return budget - string.length - 2; case List list: diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart index 307c4533b..ab4ae0f8e 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart @@ -1,6 +1,8 @@ @TestOn('vm') library; +import 'dart:async'; +import 'dart:convert'; import 'dart:isolate'; import 'package:test/test.dart'; @@ -76,6 +78,26 @@ void main() { expect(isolate.decode('{}'), throwsStateError); }); + test('dispose waits for in-flight isolate work', () async { + final isolate = YAJsonIsolate(); + final largeJson = jsonEncode([ + for (var i = 0; i < 5000; i++) {'id': i, 'name': 'user_$i'}, + ]); + + var decodeCompleted = false; + final pending = isolate.decode(largeJson).then((_) { + decodeCompleted = true; + }); + + await dispose(isolate); + // One event loop turn lets the completion listeners of the awaited + // work run; the isolate round trip itself takes far longer, so this + // fails when disposal stops awaiting in-flight work. + await Future.delayed(Duration.zero); + expect(decodeCompleted, isTrue); + await pending; + }); + test('encodes an unsendable value inline', () async { final isolate = YAJsonIsolate(); addTearDown(() => dispose(isolate)); From 3ca40447928ca6bc3cc748e1469e3b070a9d6551 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 20 Aug 2026 14:49:46 +0200 Subject: [PATCH 5/5] fix: snapshot inputs before yielding and assert the full encoded structure in tests --- .../lib/src/_isolates_io.dart | 12 +++++++++--- .../lib/src/_isolates_web.dart | 5 ++--- .../test/yet_another_json_isolate_io_test.dart | 6 +++++- .../test/yet_another_json_isolate_test.dart | 7 +++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index 5e6ac8d6f..de8f49104 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -88,6 +88,8 @@ class YAJsonIsolate { /// Decodes [json] into Dart values, like [jsonDecode]. /// /// Small payloads are decoded inline, large ones on a short lived isolate. + /// The threshold compares the length in UTF-16 code units, which is what + /// the cost of parsing a string scales with. Future decode(String json) async { _throwIfDisposed(); if (json.length < _isolateThresholdBytes) { @@ -104,14 +106,16 @@ class YAJsonIsolate { /// handed to the decoding isolate in constant time, and the UTF-8 and JSON /// decoding steps are fused, so the calling isolate never pays for /// materializing the intermediate string. + /// + /// The bytes are read before control returns to the caller, so the buffer + /// can be reused as soon as the call returns. Future decodeBytes(Uint8List encodedJson) async { _throwIfDisposed(); if (encodedJson.length < _isolateThresholdBytes) { - await null; return _utf8JsonDecoder.convert(encodedJson); } final transferable = TransferableTypedData.fromList([encodedJson]); - return _runTracked( + return await _runTracked( () => _utf8JsonDecoder.convert(transferable.materialize().asUint8List()), ); } @@ -122,10 +126,12 @@ class YAJsonIsolate { /// lived isolate. A value that cannot be sent to an isolate, for example an /// object whose `toJson()` result is encodable while the object itself /// holds a `ReceivePort`, is encoded inline instead. + /// + /// The value is consumed before control returns to the caller, so it can + /// be mutated as soon as the call returns. Future encode(Object? json) async { _throwIfDisposed(); if (_remainingBudget(json, _isolateThresholdBytes, 0) >= 0) { - await null; return jsonEncode(json); } try { diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_web.dart b/packages/yet_another_json_isolate/lib/src/_isolates_web.dart index ee7a829fa..47f4b0203 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_web.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_web.dart @@ -24,9 +24,8 @@ class YAJsonIsolate { return jsonDecode(json); } - Future decodeBytes(Uint8List encodedJson) async { - await null; - return _utf8JsonDecoder.convert(encodedJson); + Future decodeBytes(Uint8List encodedJson) { + return Future.sync(() => _utf8JsonDecoder.convert(encodedJson)); } Future encode(Object? json) async { diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart index ab4ae0f8e..17660f258 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart @@ -120,7 +120,11 @@ void main() { ]; final encoded = await isolate.encode(large); - expect(encoded, endsWith('{"type":"unsendable"}]')); + final decoded = jsonDecode(encoded) as List; + expect(decoded, hasLength(5001)); + expect(decoded.first, {'id': 0, 'name': 'user_0'}); + expect(decoded[4999], {'id': 4999, 'name': 'user_4999'}); + expect(decoded.last, {'type': 'unsendable'}); }, ); }); diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart index 8f23d7b9f..2f5e9d917 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart @@ -152,6 +152,13 @@ void main() { throwsFormatException, ); }); + + test('reads the buffer before the caller can mutate it', () async { + final bytes = Uint8List.fromList(utf8.encode(_jsonString)); + final pending = isolate.decodeBytes(bytes); + bytes.fillRange(0, bytes.length, 0x20); + expect(await pending, _jsonMap); + }); }); group('large payloads', () {