1
\$\begingroup\$

This class synchronizes access to an async resource. If retainFutureErrors is false, it will keep retrying until there is a success. Otherwise, the locked method will return a Future with an error. This will form the basis of getting dependencies in ioc_container in the next version. By default, the container should not allow reentrancy unless there is an error, and the container should not store a Future that has an error.

import 'dart:async';

/// A lock that ensures that only one async function executes at a time.
class AsyncLock<T> {
  /// Creates a new [AsyncLock].
  AsyncLock({this.retainFutureErrors = false});

  Completer<T>? _completer;

  /// Whether to retain errors or allow reentrancy until the Future completes 
  /// successfully.
  final bool retainFutureErrors;

  /// Executes the given [function] and returns the value, but ensures that 
  /// only one async function executes at a time.
  Future<T> locked(FutureOr<T> Function() function) async =>
      _completer?.future ?? _executeFunction(function);

  /// Creates a new [Completer], executes the given [function] and
  /// returns the value.
  Future<T> _executeFunction(FutureOr<T> Function() function) async {
    _completer = Completer<T>();
    try {
      final result = await function();
      _completer!.complete(result);
      return result;
      // ignore: avoid_catches_without_on_clauses
    } catch (error, stackTrace) {
      if (retainFutureErrors) {
        _completer!.completeError(error, stackTrace);
      } else {
        _completer = null;
      }
      rethrow;
    }
  }
}
```
\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

Error Handling: The locked method will retry indefinitely if retainFutureErrors is set to false and the future fails. A timeout mechanism or a cap on the number of retries would be preferable in this situation because it could result in an infinite loop. Additionally, when the Completer instance is nullified in the catch block, it is not clear why this is done.

Threading: While the code uses a Completer to ensure that only one async function executes at a time, it is not clear how this will work with multiple isolates or threads.

Here are some tests:


import 'dart:async';

/// A lock that ensures that only one async function executes at a time.
class AsyncLock<T> {
  /// Creates a new [AsyncLock].
  AsyncLock({this.retainFutureErrors = false});

  Completer<T>? _completer;

  /// Whether to retain errors or allow reentrancy until the Future completes 
  /// successfully.
  final bool retainFutureErrors;

  /// Executes the given [function] and returns the value, but ensures that 
  /// only one async function executes at a time.
  Future<T> locked(FutureOr<T> Function() function) async =>
      _completer?.future ?? _executeFunction(function);

  /// Creates a new [Completer], executes the given [function] and
  /// returns the value.
  Future<T> _executeFunction(FutureOr<T> Function() function) async {
    _completer = Completer<T>();
    try {
      final result = await function();
      _completer!.complete(result);
      return result;
      // ignore: avoid_catches_without_on_clauses
    } catch (error, stackTrace) {
      if (retainFutureErrors) {
        _completer!.completeError(error, stackTrace);
      } else {
        _completer = null;
      }
      rethrow;
    }
  }
}

class AsyncLockReentryError implements Exception {
  @override
  String toString() => 'AsyncLockReentryError: Cannot re-enter locked function.';
}


void main() {
  group('AsyncLock', () {
    test('should execute function when lock is available', () async {
      final asyncLock = AsyncLock<int>();
      final result = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 1));
      expect(result, equals(1));
    });

    test('should execute multiple functions in sequence', () async {
      final asyncLock = AsyncLock<int>();
      final firstResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 1));
      expect(firstResult, equals(1));
      final secondResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 2));
      expect(secondResult, equals(2));
    });

    test('should execute function after previous lock completes', () async {
      final asyncLock = AsyncLock<int>();
      final firstResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 1));
      expect(firstResult, equals(1));
      final secondResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 2));
      expect(secondResult, equals(2));
      final thirdResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 3));
      expect(thirdResult, equals(3));
    });

    test('should execute function after previous lock completes even with errors', () async {
      final asyncLock = AsyncLock<int>(retainFutureErrors: true);
      final firstResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 1));
      expect(firstResult, equals(1));
      await expectLater(
        asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => throw Exception('error'))),
        throwsA(isA<Exception>()),
      );
      final thirdResult = await asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => 3));
      expect(thirdResult, equals(3));
    });

    test('should throw error when retainFutureErrors is false', () async {
      final asyncLock = AsyncLock<int>();
      final future = asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => throw Exception('error')));
      await expectLater(future, throwsA(isA<Exception>()));
    });

    test('should return result with error when retainFutureErrors is true', () async {
      final asyncLock = AsyncLock<int>(retainFutureErrors: true);
      final future = asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => throw Exception('error')));
      await expectLater(future, throwsA(isA<Exception>()));
      await expectLater(future.catchError((e) => e), isA<Exception>());
    });

    test('should not allow reentrancy by default', () async {
      final asyncLock = AsyncLock<int>();
      final future = asyncLock.locked(() => asyncLock.locked(() => Future.value()));
      await expectLater(future, throwsA(isA<AsyncLockReentryError>()));
    });

    test('should allow reentrancy if there is an error', () async {
      final asyncLock = AsyncLock<int>(retainFutureErrors: true);
      final future = asyncLock.locked(() => asyncLock.locked(() => Future.delayed(const Duration(milliseconds: 500), () => throw Exception('error'))));
     


      });
  });
}```
\$\endgroup\$
11
  • \$\begingroup\$ Nice one. Thanks \$\endgroup\$ May 7 at 22:36
  • \$\begingroup\$ Question: how would this work across isolates? Dart doesn't allow sharing of resources across isolates so what kind of scenario would cause an issue? \$\endgroup\$ May 7 at 22:41
  • \$\begingroup\$ I think these tests expose that this class is not a complete solution and cannot be used as a generic lock. I think it will only work in specific scenarios. This test: should execute multiple functions in sequence proves that. \$\endgroup\$ May 7 at 22:44
  • \$\begingroup\$ I updated the code accordingly, but unfortunately, this breaks compilation on several of your tests \$\endgroup\$ May 7 at 23:17
  • \$\begingroup\$ Sending messages between isolates allows you to still talk to each other. To send and receive messages between isolates, for instance, you can use the SendPort and ReceivePort classes. Since isolates cannot directly access each other's resources, this enables you to share information and plan actions between them. \$\endgroup\$ May 9 at 6:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.