aws-amplify / amplify-flutter

A declarative library with an easy-to-use interface for building Flutter applications on AWS.
https://docs.amplify.aws
Apache License 2.0
1.31k stars 242 forks source link

Memory leak when configuring amplify #5161

Closed kamami closed 2 weeks ago

kamami commented 1 month ago

Description

I am configuring amplify in a function, which is called multiple times by native code while the app is running. It is designed to track the users location even in the background. I also want to access my API Gateway and Authentication Services there to send the GPS data to my AWS backend.

I am observing following:

When calling _configureAmplify I get a permanent increased memory usage on my iPhone 15 pro. When call _configureAmplify, but without the lines await Amplify.addPlugins([authPlugin, apiPlugin]); I do not get an increased memory usage. But this is not an option, because I need those plugins.

import 'dart:convert';
import 'package:amplify_api/amplify_api.dart';
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:codrive/repositories/api_repository.dart';
import 'package:codrive/repositories/auth_repository.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

Future<void> _configureAmplify() async {
  const String env = String.fromEnvironment('ENV');
  String configFile = 'assets/config/config.$env.json';

  try {
    String s = await rootBundle.loadString(configFile);
    Map<String, dynamic> config = jsonDecode(s);
    AmplifyAuthCognito authPlugin = AmplifyAuthCognito();
    AmplifyAPI apiPlugin = AmplifyAPI();
    await Amplify.addPlugins([authPlugin, apiPlugin]);

    await Amplify.configure(jsonEncode(config['amplifyConfig']));
  } on AmplifyAlreadyConfiguredException {
    print(
        'Tried to reconfigure Amplify; this can occur when your app restarts on Android.');
  } catch (e) {
    print('Failed to configure Amplify: $e');
  }
}

@pragma('vm:entry-point')
void backgroundCallbackDispatcher() async {
  WidgetsFlutterBinding.ensureInitialized();
  print(Amplify.isConfigured);
  await _configureAmplify();

  final apiRepository = APIRepository();
  final authRepository = AuthRepository();

   //track users location

}

Categories

Steps to Reproduce

Run _configureAmplify with and without await Amplify.addPlugins([authPlugin, apiPlugin]);

Screenshots

This is the chart, when adding both plugins before configuration. Every step is one invocation of backgroundCallbackDispatcher.

image

This is the chart, when NOT adding both plugins before configuration.

image

Platforms

Flutter Version

3.22.2

Amplify Flutter Version

2.2.0

Deployment Method

Amplify CLI

Schema

No response

Equartey commented 1 month ago

Hi @kamami,

Typically, we recommend using Amplify.isConfigured to ensure Amplify.configure only runs when the app is in an unconfigured state. Is something like this viable for you?

@pragma('vm:entry-point')
void backgroundCallbackDispatcher() async {
  WidgetsFlutterBinding.ensureInitialized();
  print(Amplify.isConfigured);
  // conditionally configure
  if (!Amplify.isConfigured) {
    await _configureAmplify();
  }
  final apiRepository = APIRepository();
  final authRepository = AuthRepository();

   //track users location

}

If not, can you further explain your use case for calling Amplify.configure multiple times?

kamami commented 1 month ago

Hi @Equartey , thanks for the quick response. I already tried Amplify.isConfigured, but it is always false. The backgroundCallbackDispatcher is another entry-point for my app. This part of the app is meant to run in the background even if the app is detached. The location tracking is done by an SDK which uses iOS microtasks to invoke and run the backgroundCallbackDispatcher directly. Since iOS microtasks are killed by the OS after 20-30 secs, the backgroundCallbackDispatcher is invoked again, which triggers a new Amplify configuration. The configuration is not the problem, but adding the plugins before configuration causes the increased memory usage.

I think using Amplify in an additional entry point (@pragma('vm:entry-point')) is a valid use case, but that also means multiple configurations need to be handled correctly.

BTW. using the same approach with Firebase/Firestore is working perfectly fine. The multiple initializations of Firebase are not causing any problems.

@pragma('vm:entry-point')
void backgroundCallbackDispatcher() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  await FirebaseAuth.instance.signInAnonymously();
  const uuid = Uuid();

  SafeDrivePodSDK.podData.stream().listen((event) async {
    if (event is PodDateTimeRead) {
      // Wait 15 seconds to ensure the connection is stable
      if (Platform.isIOS) {
        await Future<void>.delayed(
          const Duration(seconds: 15),
        );
      }

      SafeDrivePodSDK.podData.downloadTrips(
        fromDate: DateTime.now().subtract(
          const Duration(days: 365 * 10),
        ),
      );
    }

    final userId = FirebaseAuth.instance.currentUser?.uid ?? 'anonymous';
    final date = DateTime.now();
    final day = DateFormat('yyyy-MM-dd').format(date);
    final key = '${DateFormat('HH:mm:ss:SS').format(date)}_[${uuid.v4()}]';
    final path = 'users/$userId/data/$day/events/$key';

    FirebaseFirestore.instance.doc(path).set(
          event.toMap(),
        );
  });
}
Equartey commented 1 month ago

Thank you for the context, that's really helpful.

Couple comments and questions:

kamami commented 1 month ago
Bildschirmfoto 2024-07-15 um 20 36 13

The red errors show the time when await Amplify.addPlugins([authPlugin, apiPlugin]); was invoked. I would not mind the increase of memory, but it never drops down again. Each configuration (only when adding plugins before) is increasing the level permanently.

Equartey commented 1 month ago

Hi @kamami, we're working on reproducing this to validate some assumptions.

In the meantime, we suspect that each call to backgroundCallbackDispatcher spawns a new isolate without context of previous configure calls. This would explain why Amplify.isConfigured is returning false.

If this hypothesis is correct, calling Amplify.reset() after your business logic may help by cleaning up memory to avoid additional calls consuming more. Can you give that a try and report your findings?

kamami commented 1 month ago

The point when my business logic is done, is not always clear. The backgroundCallbackDispatcher is not only called again, when the app is killed. In this case I could observe the lifecycle of the app. Instead it is also called sometimes a second and third time, while the app is running. But this should not harm the performance as it unfortunately currently does. Here is my code for now. As you can see I reset Amplify before I do another configuration:

Future<void> _configureAmplify() async {
  try {
    await Amplify.reset();
    const String env = String.fromEnvironment('ENV');
    String configFile = 'assets/config/config.$env.json';
    String s = await rootBundle.loadString(configFile);
    Map<String, dynamic> config = jsonDecode(s);
    AmplifyAuthCognito authPlugin = AmplifyAuthCognito();
    AmplifyAPI apiPlugin = AmplifyAPI();
    await Amplify.addPlugins([authPlugin, apiPlugin]);

    await Amplify.configure(jsonEncode(config['amplifyConfig']));
  } on AmplifyAlreadyConfiguredException {
    print('Tried to reconfigure Amplify; this can occur when your app restarts on Android.');
  } catch (e) {
    print('Failed to configure Amplify: $e');
  }
}

@pragma('vm:entry-point')
void backgroundCallbackDispatcher() async {
  WidgetsFlutterBinding.ensureInitialized();
  await _configureAmplify();

  final apiRepository = APIRepository();
  final authRepository = AuthRepository();

  try {
    SafeDrivePodSDK.podData.stream().listen((PodDataEvent event) async {
      await _handlePodEvent(event, apiRepository, authRepository);
    }, onError: (error) {
      safePrint('Stream error: $error');
    }, onDone: () {
      safePrint('Stream closed');
    }, cancelOnError: true);
  } catch (e) {
    safePrint('Error in background callback dispatcher: $e');
  }
}
Equartey commented 1 month ago

@kamami I've been unsuccessful with my attempts to observe a consistent memory increase like the graph you shared. I've seen memory usage spike, but it returns to base line after a few seconds. Given the challenge of recreating this environment, offering more specific steps or a sample app could be beneficial.

Ultimately, until we have official support for making multiple calls to Amplify.configure(), potential workarounds are:

Jordan-Nelson commented 1 month ago

From offline discussions, it sounds like the issue here is caused by configuring Amplify from multiple isolates. This is not something we officially support. We use Amplify.reset() internally to release resources in tests, but it does not appear that this frees up all (or even most) of the memory. Even if the memory issue were resolved, there could be other issues you find from running multiple amplify instances in multiple isolates. I am not sure that all the interactions with device storage would be safe if executed from multiple isolates in parallel. We could create a feature request to support this. I am not certain of the level of effort for this though.

The best solution here is probably to find a way to only configure and call Amplify on a single isolate (ideally the main isolate). I think you could could use a receive port and send port to send messages between the main isolate and the new isolate that is spun up each time. Please see https://dart.dev/language/isolates#sending-multiple-messages-between-isolates-with-ports.

As for the differences between Firebase and Amplify - I cannot speak in detail about Firebase, but at a high level Firebase in Flutter is just a wrapper around a set of native iOS/Android/JS Firebase libraries. It likely doesn't matter how many Dart isolates you spin up since there is likely very little resources consumed on the dart side. Amplify Flutter is written primarily in Dart. Native code is only invoked when needed to interact with native APIs. We favor a dart first approach since it allows us to support all platforms that Flutter supports, ensures consistent behavior across those platforms, is the language that most of our customers are most familiar with, and it makes debugging much simpler.

Let us know if you have any questions.

Jordan-Nelson commented 3 weeks ago

@kamami - Let me know if you have any further questions, or if you are interested in opening a feature request to track official support for using Amplify from multiple isolates.

Jordan-Nelson commented 2 weeks ago

@kamami I am going to close this issue. We have opened https://github.com/aws-amplify/amplify-flutter/issues/5302 to track interest in using isolates