Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] "Out of sync" data in Firestore #1083

Open
Nyankoo opened this issue Jun 28, 2021 · 27 comments
Open

[Question] "Out of sync" data in Firestore #1083

Nyankoo opened this issue Jun 28, 2021 · 27 comments

Comments

@Nyankoo
Copy link

Nyankoo commented Jun 28, 2021

[REQUIRED] Please fill in the following fields:

  • Unity editor version: 2020.3.9f1
  • Firebase Unity SDK version: 7.2.0
  • Source you installed the SDK: Unity Package Manager with tgz
  • Problematic Firebase Component: Firestore
  • Other Firebase Components in use: Auth, Crashlytics, Remote Config
  • Additional SDKs you are using:
  • Platform you are using the Unity editor on: Windows
  • Platform you are targeting: Android
  • Scripting Runtime: IL2CPP

[REQUIRED] Please describe the question here:

I stumbled about the following information when looking into Firestore's cache behavior:

These things (set(), update(), or delete()) will happen eventually, if the app comes back online and the SDK is able to sync. But if the app process dies while offline and is relaunched online, those methods of tracking the completion of the operation will no longer work.

When I understand this correctly, data saved to the cache before the app dies/stops is not automatically updated to the Firestore backend when the app launches again.

Is this correct and when yes, what would be the best way to handle the "out of sync" data only present in the cache?

@Nyankoo Nyankoo changed the title [Question] [Question] "Out of sync" data in Firestore Jun 28, 2021
@dconeybe dconeybe self-assigned this Jun 28, 2021
@dconeybe dconeybe removed the new New issue. label Jun 28, 2021
@dconeybe
Copy link

Hi @Nyankoo. A quick Google search for that quote lead me to this article: https://medium.com/firebase-developers/firestore-clients-to-cache-or-not-to-cache-or-both-8f66a239c329. Is this where you copied that quote from?

Assuming it is, I believe you are asking if writes made while offline will eventually be sync'd to the backend when the app goes back online, even if interrupted by an app restart. If that is your question then the answer is "yes". Although the promise/task/callback initially attached to the write operation no longer exist after the app is restarted, the write is recorded in the local cache and will be sync'd with the backend once connectivity is restored.

Does this answer your question?

@Nyankoo
Copy link
Author

Nyankoo commented Jun 28, 2021

Hi @dconeybe, thank you for the quick answer!
Yep, that's where the quote is from! Sorry about that, I forgot to add the link to the article in my post.

When attaching a listener directly on the app's startup, is this sync then reflected in querySnapshot.Metadata.HasPendingWrites when querySnapshot.Metadata.IsFromCache is true?

@dconeybe
Copy link

dconeybe commented Jun 28, 2021

Yes. HasPendingWrites will change to false once the write has been written to the backend. IsFromCache may still be true if the document has not yet been read from the backend, which means there may be changes that occurred to the document by other apps/users that have not been downloaded into the local cache.

@Nyankoo
Copy link
Author

Nyankoo commented Jun 28, 2021

@dconeybe Just to confirm: HasPendingWrites will change to true when the write is successfully send to the backend? Shouldn't it be false instead?

@dconeybe
Copy link

Oops, yes. Sorry. That was a typo (I've edited that comment to correct it). You are correct: HasPendingWrites will change to false once the write has been successfully written to the backend.

Note that you can also call FirebaseFirestore.WaitForPendingWritesAsync() to get a Task that completes when all writes have completed.

@Nyankoo
Copy link
Author

Nyankoo commented Jun 28, 2021

@dconeybe I'm experiencing a really weird issue when testing offline-syncing. Here is simplified code to see the call order:

DisableNetworkAsync();
CollectionReference.Listen();
> IsFromCache is true
> someValue contains 1
DocumentReference.SetAsync(someValue = 2);
> someValue is now 2

> Quit app
> Remove DisableNetworkAsync();

CollectionReference.Listen();
IsFromCache is true
> someValue is still(!) 1 (looks like the backend value even when IsFromCache is true)

> Quit app
> Add DisableNetworkAsync(); back in

DisableNetworkAsync();
CollectionReference.Listen();
IsFromCache is true
> someValue is 2 (back to cached value)

This behavior is reproduceable on my end every time I add or remove DisableNetworkAsync, but our project is too big to create a sample project from it in the near future.

Maybe I'm missing something here anyway, so it would be great when you could take a look.

@dconeybe
Copy link

@Nyankoo This definitely does not sound like intended behavior.

A few questions:

  1. Does this only happen on Android? Have you been able to try on iOS or in the Unity Editor?
  2. Can you reproduce this in an isolated app that just does the steps you outlined? Or is it only when these steps are exercised in your large app?
  3. Can you enable debug logging by setting FirebaseFirestore.LogLevel to Debug, reproduce, capture the logs, and post them?

Thank you.

@Nyankoo
Copy link
Author

Nyankoo commented Jun 29, 2021

@dconeybe

  1. I currently only tested this in the Unity Editor.
  2. I sadly didn't had the time to create an extra app for this issue
  3. You can find the editor log with FirebaseFirestore.LogLevel Debug enabled here:
    firestore.txt

You can search for "Repetitions" to see our manual log outputs to see the value change.

@dconeybe
Copy link

dconeybe commented Jun 30, 2021

Hi @Nyankoo. I attempted to reproduce the behavior you're noting but did not have success. I observed the expected behavior.

Here the test script that I used. Each time I would uncomment exactly one of the "steps" and re-run the app in the Unity Editor.

using Firebase.Extensions;
using Firebase.Firestore;
using System.Collections.Generic;
using UnityEngine;

public class UnityIssue1083 : MonoBehaviour {

  void Start() {
    Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task => {
      if (task.IsFaulted) {
        Debug.LogError("zzyzx CheckAndFixDependenciesAsync() FAILED: "   task.Exception);
        return;
      }

      FirebaseFirestore db = FirebaseFirestore.DefaultInstance;
      DocumentReference doc = db.Document("UnityIssue1083-2/testdoc");

      // Uncomment exactly one of the steps below.
      //Step1(db, doc);
      //Step2(db, doc);
      //Step3(db, doc);
    });
  }

  private void Step1(FirebaseFirestore db, DocumentReference doc) {
    var data = new Dictionary<string, object> {
      {"TestKey", 4321}
    };

    Debug.Log("zzyzx Step1() SetAsync() start");
    doc.SetAsync(data).ContinueWithOnMainThread(task => {
      Debug.Log("zzyzx Step1() SetAsync() completed");
      if (task.Exception != null) {
        Debug.LogError("zzyzx Step1() SetAsync() FAILED: "   task.Exception);
      }
    });

    doc.Parent.Listen(MetadataChanges.Include, snapshot => {
      Debug.Log("zzyzx Step1() Listen callback: "   logLineFromSnapshot(snapshot));
    });
  }

  private void Step2(FirebaseFirestore db, DocumentReference doc) {
    db.DisableNetworkAsync().ContinueWithOnMainThread(task1 => {
      doc.Parent.Listen(MetadataChanges.Include, snapshot => {
        Debug.Log("zzyzx Step2() Listen callback: "   logLineFromSnapshot(snapshot));
      });

      doc.SetAsync(new Dictionary<string, object> { {"TestKey", 5555} }).ContinueWithOnMainThread(task2 => {
        Debug.LogError("zzyzx Step2() SetAsync() completed, but should never get here since the Task should not complete while offline");
        if (task2.Exception != null) {
          Debug.LogError("zzyzx Step2() SetAsync() FAILED: "   task2.Exception);
        }
      });
    });
  }

  private void Step3(FirebaseFirestore db, DocumentReference doc) {
    doc.Parent.Listen(MetadataChanges.Include, snapshot => {
      Debug.Log("zzyzx Step3() Listen callback: "   logLineFromSnapshot(snapshot));
    });
  }

  private static string logLineFromSnapshot(QuerySnapshot snapshot) {
    var message = "Count="   snapshot.Count  
        " HasPendingWrites="   snapshot.Metadata.HasPendingWrites  
        " IsFromCache="   snapshot.Metadata.IsFromCache;

    if (snapshot.Count > 0) {
      message  = " TestKey="   snapshot[0].GetValue<int>("TestKey", ServerTimestampBehavior.None);
    }

    return message;
  }

}

Each time I observed the expected output, which looked like this:

zzyzx Step1() SetAsync() start
zzyzx Step1() Listen callback: Count=1 HasPendingWrites=True IsFromCache=True TestKey=4321
zzyzx Step1() Listen callback: Count=1 HasPendingWrites=True IsFromCache=False TestKey=4321
zzyzx Step1() Listen callback: Count=1 HasPendingWrites=False IsFromCache=False TestKey=4321
zzyzx Step1() SetAsync() completed

zzyzx Step2() Listen callback: Count=1 HasPendingWrites=False IsFromCache=True TestKey=4321
zzyzx Step2() Listen callback: Count=1 HasPendingWrites=True IsFromCache=True TestKey=5555

zzyzx Step3() Listen callback: Count=1 HasPendingWrites=True IsFromCache=True TestKey=5555
zzyzx Step3() Listen callback: Count=1 HasPendingWrites=True IsFromCache=False TestKey=5555
zzyzx Step3() Listen callback: Count=1 HasPendingWrites=False IsFromCache=False TestKey=5555

I'm going to comb through your logs now and see if anything looks suspicious. Can you take a look at my code here to see if it accurately captures what you're trying to do?

@Nyankoo
Copy link
Author

Nyankoo commented Jun 30, 2021

@dconeybe Thank you for the example code! This is the way we're currently testing it on our end.

I added the logging part of your code to our app and one thing I noticed was that when I disabled the network, the listener reported HasPendingWrites to be true without me doing any SetAsync operations. This is probably still from my previous testing.
After restarting the app with the network enabled, the listener directly reported HasPendingWrites to be false, but no update was sent to the backend.

@dconeybe
Copy link

That's very odd what you're seeing. Unfortunately, I will be out of the office for the next few days so won't get back to investigating until mid-next week. In the meantime, if you can create a minimal app that I can use to reproduce that would be incredibly helpful. Otherwise, we may have to do some debug-based logging. Good luck! I'll touch base next week.

@Nyankoo
Copy link
Author

Nyankoo commented Jul 3, 2021

@dconeybe I tested a bit more and can confirm that your test code works on my end, while even with Firebase 8.0.0, the issue for my Listeners still happens. I tried different Listeners (on a DocumentReference and a CollectionReference), both with the same result.

It seems like the "online" and "offline" data are two separate data sets. When online, everything works as normal and the listener grabs the correct cached data (IsFromCache=True) when creating it, but when using DisableNetworkAsync, a "different" data set is being loaded while IsFromCache=True.

I noticed this when I saw the following behavior:

Step 1

  • ClearPersistenceAsync
  • docReference.Listen
  • returns correct data with IsFromCache=False (this should populate the cache, correct?)

Step 2 (to check that cache works)

  • docReference.Listen
  • returns correct data with IsFromCache=True

Step 3

  • DisableNetworkAsync
  • docReference.Listen
  • returns no data with IsFromCache=True (like mentioned above, seems like a different data set is loaded from cache)

Step 4 (to check "online" cache)

  • docReference.Listen
  • returns correct data with IsFromCache=True

Another thing I noticed on Android (everything else is currently only tested in the Unity Editor):
When using DisableNetworkAsync, the first Listener callback reports IsFromCache=True, but after that, the callback is invoked again with IsFromCache=False. This doesn't sound like the correct behavior (tested with MetadataChanges.Include).

@Nyankoo
Copy link
Author

Nyankoo commented Jul 7, 2021

@dconeybe I can also confirm that calling EnableNetworkAsync as the last point in Step 3 above successfully updates the data on the backend.
This maybe indicates that an app (or Unity editor) restart creates this issue.

@dconeybe
Copy link

dconeybe commented Jul 8, 2021

I suspect there may be a race condition relating to authentication. From the logs you provided, it appears that some writes to Firestore are done pre-authentication and others are done post-authentication.

The writes that are made pre-authentication that are not sync'd to the backend will be paused once authentication succeeds (they will only be resumed if the user subsequently logs out). This would explain why you are seeing the "old value" after enabling the network, because the locally-cached write made while offline and unauthenticated was effectively dropped because the user was not authenticated and now the user is authenticated.

I can't confirm this hypothesis yet and I'm still investigating. But I'm wondering if you can test a solution out for me: avoid performing any writes to Firestore until after the user has signed in and authenticated successfully. In other words, ensure that FirebaseAuth.CurrentUser returns non-null before performing any SetAsync() calls. Can you let me know if this fixes the inconsistency that you are observing?

@Nyankoo
Copy link
Author

Nyankoo commented Jul 8, 2021

@dconeybe All writes via Firestore are made after I authenticated the user, but maybe Firestore tries to sync pending writes before the authentication is finished? Is there maybe a way to test/see this?

On that note, is Auth affected by EnableNetworkAsync? I assume the user gets authenticated successfully, as Auth is still "online" while Firestore "thinks it is offline"?

@dconeybe
Copy link

dconeybe commented Jul 8, 2021

I'm not sure what you mean by "maybe Firestore tries to sync pending writes before the authentication is finished". If you never call SetAsync() prior to a successful authentication then where would these pending writes come from that would be sent before authentication has finished?

Calling FirebaseFirestore.EnableNetworkAsync() or FirebaseFirestore.EnableNetworkAsync() only affects Firestore; FirebaseAuth would not be affected.

@Nyankoo
Copy link
Author

Nyankoo commented Jul 8, 2021

@dconeybe I meant this:

  • Calling SetAsync() with an authenticated user and DisableNetworkAsync
  • Removing DisableNetworkAsync and restarting the app
  • [Firestore should now automatically try to send the pending write to the backend] <- could this happen before Auth successfully authenticates the current user?

@dconeybe
Copy link

dconeybe commented Jul 8, 2021

Hmm, okay I think I may have reproduced what you are describing. Could you confirm?

Here is the code:

using Firebase.Auth;
using Firebase.Extensions;
using Firebase.Firestore;
using System;
using System.Collections.Generic;
using UnityEngine;

public class UnityAuthOutOfSync : MonoBehaviour {

  async void Start() {
    await Firebase.FirebaseApp.CheckAndFixDependenciesAsync();

    var docPath = "UnityAuthOutOfSync/testdoc";
    int step = 1;

    if (step == 1) {
      var auth = FirebaseAuth.DefaultInstance;
      if (auth.CurrentUser == null) {
        await auth.SignInAnonymouslyAsync();
      }

      var db = FirebaseFirestore.DefaultInstance;
      DocumentReference doc = db.Document(docPath);

      doc.Listen(MetadataChanges.Include, snapshot => {
        Debug.Log("Listen callback: "   StringFromDocumentSnapshot(snapshot));
      });

      await doc.SetAsync(new Dictionary<string, object> { {"TestKey", 11111} });
      await db.DisableNetworkAsync();
      await doc.SetAsync(new Dictionary<string, object> { {"TestKey", 54321} });
    }

    if (step == 2) {
      var db = FirebaseFirestore.DefaultInstance;
      DocumentReference doc = db.Document(docPath);
      doc.Listen(MetadataChanges.Include, snapshot => {
        Debug.Log("Listen callback: "   StringFromDocumentSnapshot(snapshot));
      });
    }
  }

  private static string StringFromDocumentSnapshot(DocumentSnapshot snapshot) {
    return "TestKey="   snapshot.GetValue<int>("TestKey")   " IsFromCache="
          snapshot.Metadata.IsFromCache   " HasPendingWrites="
          snapshot.Metadata.HasPendingWrites;
  }

}

Steps to Reproduce:

  1. Edit the code to set int step = 1
  2. Run the code in the Unity Editor
  3. Edit the code to set int step = 2
  4. Run the code in the Unity Editor

Expected Results:
In both steps 2 and 4 above, the Listen() callback is ultimately notified that "TestKey" changes its value to "54321".

Actual Results:
In step 2 above, the Listen() callback is ultimately notified that "TestKey" changes its value to "54321" (as expected); however, in step 4 the Listen() callback is notified that the value of "TestKey" is "11111", the original value that was set. It appears that the new value of "54321" is never uploaded to the server and gets lost.

@Nyankoo
Copy link
Author

Nyankoo commented Jul 8, 2021

@dconeybe Yes, this also happens when I execute your code on my end!

@dconeybe
Copy link

dconeybe commented Jul 8, 2021

Ok. Then it appears that writes made after calling DisableNetworkAsync() are getting lost if the Unity Editor gets restarted before going back online. I'll investigate and reply back. This is indeed perplexing.

@Nyankoo
Copy link
Author

Nyankoo commented Jul 8, 2021

I can also confirm that this is not happening on an Android device, so it seems to be (luckily) only an issue affecting the editor. Maybe Unity changed something in their latest editor releases that is causing this?

@dconeybe
Copy link

dconeybe commented Jul 9, 2021

I haven't found the root cause yet, but I may have found a workaround. It appears that adding any usage of FirebaseAuth.DefaultInstance before the first usage of FirebaseFirestore fixes the issue.

For example, in the code that I provided in #1083 (comment), adding the line

var auth = FirebaseAuth.DefaultInstance;

as the first statement in the if (step == 2) block fixes it; that is, it will return the updated data.

Specifically, I changed

    if (step == 2) {
      var db = FirebaseFirestore.DefaultInstance;
      DocumentReference doc = db.Document(docPath);
      doc.Listen(MetadataChanges.Include, snapshot => {
        Debug.Log("Listen callback: "   StringFromDocumentSnapshot(snapshot));
      });
    }

to

    if (step == 2) {
      var auth = FirebaseAuth.DefaultInstance; // <--- Adding this line appears to "fix" the issue of getting stale data
      var db = FirebaseFirestore.DefaultInstance;
      DocumentReference doc = db.Document(docPath);
      doc.Listen(MetadataChanges.Include, snapshot => {
        Debug.Log("Listen callback: "   StringFromDocumentSnapshot(snapshot));
      });
    }

Can you test out this workaround in your app? Basically, just add a "useless" call to FirebaseAuth.DefaultInstance before doing anything with FirebaseFirestore.

@Nyankoo
Copy link
Author

Nyankoo commented Jul 11, 2021

@dconeybe Thank you for looking into it!

I can confirm that this workaround fixes the issue in your sample, as well as in our app.

We called DisableNetworkAsync() before using FirebaseAuth.DefaultInstance and any other FirebaseFirestore methods, which seems to have caused this in our app.

@dconeybe
Copy link

Update: I've been able to reproduce this bug in the underlying Firestore C SDK. I'm continuing investigation.

If interested, here is the "test app" that I wrote to reproduce: https://gist.github.com/dconeybe/b0d72663ffb744d031cd84eab5194115

@dconeybe
Copy link

Update: I've made some progress in the investigation, although I don't have a fix yet.

The problem is in the GetCurrentUser() function fromcredentials_provider_desktop.cc in the C SDK:

User GetCurrentUser(App& app) {
  std::string uid;
  bool success = app.function_registry()->CallFunction(
      ::firebase::internal::FnAuthGetCurrentUserUid, &app, /* args= */ nullptr,
      &uid);

  // If Auth is unavailable, treat the user as unauthenticated.
  if (!success) return {};

  return User(uid);
}

https://github.com/firebase/firebase-cpp-sdk/blob/ccee89c59f0bdafc419a4177c1782fef8025f2dd/firestore/src/main/credentials_provider_desktop.cc#L79-L89

Basically, when FirebaseAuth.DefaultInstance is invoked from C# it triggers Firebase Auth's initialization code, which registers the FnAuthGetCurrentUserUid function. Then, in Firestore's initialization, it uses the registered function to determine the current user's ID. However, if Firebase Auth is never referenced then the initialization flow is never executed and the FnAuthGetCurrentUserUid function is not registered. Since Firestore associates each write with a uid, if the uid of a queued write is different from the current user's uid then the non-matching write will be lost.

This fix will be to ensure that Firebase Auth's initialization flow executes before trying to run the FnAuthGetCurrentUserUid function.

The good news is that this bug only affects desktop platforms (MacOS, Windows, Linux) but does not affect the mobile platforms (iOS and Android).

@dconeybe
Copy link

Hmm, there is a bit of a "chicken and egg" problem here. In cl/353905031 (only visible to Googlers) Firestore converted its dependency on Firebase Auth from a "hard" dependency (i.e. directly using firebase::auth::Auth) to a "soft" dependency (i.e. using a "function registry" as an intermediary) so that binaries using Firestore were not forced to link with auth.

However, Firebase Auth does not register its functions with the function registry until it is first used; therefore, if it is never used then the function will never be registered and Firestore will erroneously run in "unauthenticated" mode. So there is an implicit hard requirement that Firestore apps that use Auth explicitly call some Auth method so that its initialization will occur, registering the function in the registry.

I'll work with the team on sorting this out.

@dconeybe
Copy link

The fix for this bug is, unfortunately, not trivial. Since this bug (1) only affects desktop platforms and not mobile platforms and (2) has an easy workaround, I'm going to shelve investigation for now. I'll keep this ticket opened though.

TL;DR the workaround is to ensure that there exists some call to FirebaseAuth.DefaultInstance, even a "useless" one that just discards the return value, in your code before using the FirebaseFirestore class. This will trigger the Firebase Auth SDK to initialize itself and avoid the problem where Firestore is used and Auth is uninitialized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants