firebase-android-sdk icon indicating copy to clipboard operation
firebase-android-sdk copied to clipboard

Get from server immediately after a document is updated gives old cached data when snapshot is open

Open rorystephenson opened this issue 7 months ago • 9 comments

Describe your environment

  • Android Studio version: Meerkat #AI-243.25659.59.2432.13423653
  • Firebase Component: Firestore
  • Component version: Using Flutter cloud_firestore 5.6.7

Describe the problem

NOTE: I have been directed to share this here after opening an issue in the flutter firebase sdk.

The short version is that when a snapshot listener for a document is already open and the app calls get(GetOptions(source: Source.server)) for that document, instead of fetching fresh data from the server cloud_firestore is returning the most recently fetched snapshot data. Moreover the isFromCache and hasPendingWrites metadata values are both false. This means we do not have a way to fetch fresh data from firestore when we know that there has been updated.

I believe this is a variant of this issue: https://github.com/firebase/flutterfire/issues/10153. The difference being that our write is done by the backend. We have verified that the backend waits for the transaction to complete and that the mobile app waits for the backend to respond before calling get().

The linked issue has been closed as 'will not fix' using https://github.com/firebase/firebase-js-sdk/issues/6915#issuecomment-1439374095 as justification however this seems incorrect for the following reasons:

  • Firestore goes to great efforts to be a strongly consistent database that guarantees that reads which happen after a transaction is committed will return the latest data. What is the point of such a guarantee if the client short-cuts it by making it impossible to actually force a fetch of fresh data from the database.
  • The documentation for Source.server in GetOptions makes no reference to this behaviour.
  • In fact that same documentation emphasises that the data is being fetched from the server by mentioning that failure to fetch the data will result in an error. In my testing this does not happen, I can turn the internet off and the get() request returns the latest data from the snapshot listener.
  • The only workaround would seem to be stopping snapshots whilst performing the get(). In a large app which may listen to documents in various places for various reasons this is not feasible and amounts to maintaining a global state somewhere to make sure that snapshots are off before a given document is fetched via get() to be sure that the returned data is up to date.
  • Even if we didn't know that the data has just been changed for our purposes we still need to get the latest version of the data as we will be updating it via the backend API and we don't want to do that from stale data. Note that making the write from the frontend is not feasible as the backend needs to do checks on the data which the frontend cannot make.

Steps to reproduce:

Same as https://github.com/firebase/flutterfire/issues/10153

In our case the transaction is run from a backend system but the outcome is the same.

rorystephenson avatar May 20 '25 17:05 rorystephenson

Hi @rorystephenson , thank you for reporting this issue. Could you please help clarify what operations have been done, and what was expected? What do you mean by " We have verified that the backend waits for the transaction to complete and that the mobile app waits for the backend to respond before calling get()."? It would be nice if you can provide a minimal repro app with reproduction steps.

If you are trying to read from server, no cache involved, could you please try reading the doc inside a transaction? This might be the solution.

milaGGL avatar May 21 '25 19:05 milaGGL

@milaGGL so it goes like this:

  1. App is listening to snapshots for document foo.
  2. App calls our backend API which updates that same document foo.
  3. App then immediately wants to get the latest version of the foo document to perform some action so it performs a get() on that document with the get options set to get from server only.
  4. App receives stale cached data from the get() request despite specifying server only.

When I said "We have verified that the backend waits for the transaction to complete..." I am just clarifying that we have verified that this is not a result of failing to wait for the write to finish on the backend before initiating the get() in the app.

In fact I can even disable the internet and the get() will return me data despite the get options indicating to only get data from the server.

It's not very easy for me to provide a repro as triggering the race condition is not trivial, you need to:

  1. Create some doc foo for testing.
  2. Start a snapshot listener for doc foo.
  3. Whilst the snapshot is still listening, modify the foo document and then trigger the get() with getoptions server only before the snapshot listener receives the updated doc. Modifying foo via the client will probably not reproduce the error as I imagine the client will wait for the transaction and update it's local cache accordingly, so the best way to simulate this is probably calling a backend API like we do to make the change.
  4. The get() call will receive stale data, it will be the last version of the foo doc which the snapshot received before the change was made.

Perhaps the no-connection scenario is easier to reproduce:

  1. Create some doc foo for testing.
  2. Start a snapshot listener for doc foo.
  3. Disable internet.
  4. Trigger the get() with getoptions server only.
  5. The get() call will receive stale data, it will be the last version of the foo doc which the snapshot received before the change was made.

rorystephenson avatar May 23 '25 09:05 rorystephenson

@rorystephenson I see. Thank you for the explanation. I will look into the Source.SERVER behaviour.

In the meantime, have you tried reading the doc inside a transaction? it should give and only give you the docs from server.

milaGGL avatar May 23 '25 15:05 milaGGL

In fact I can even disable the internet and the get() will return me data despite the get options indicating to only get data from the server.

Regarding this, I am unable to reproduce the behaviour. while offline, get(Source.SERVER) should throw an exception, and our test confirms that it works as expected. https://github.com/firebase/firebase-android-sdk/blob/f03fe1c0451ac0e31e91b1c0c6916ee69b20d8cc/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SourceTest.java#L282 And if you are using get() only, did not specify the source, it would use Source.DEFAULT, which tries to get from server first, and fall back to cache if it fails.

Could you please provide a minimal repro app for it?

milaGGL avatar Jun 04 '25 15:06 milaGGL

Hi @milaGGL, I've asked the dev that directed me to open this issue from the Flutter side to share their reproduction with you as they were able to reproduce it in the native SDKs apparently.

One thing that's not clear is whether your tests were performed when a snapshot was open already for the same document before trying to call get()?

rorystephenson avatar Jun 09 '25 20:06 rorystephenson

This is what have been tested:

  1. create a random doc, and call get() on it
  2. disable the network
  3. add a snapshot listener on the doc
  4. call get(Source.CACHE) on the doc, which should succeed
  5. call get() on the doc, which should try getting doc from server first, then from cache, and succeed
  6. call get(Source.SERVER), which should fail and throw an exception.

I have tried switching the step 2 and 3, which also works as expected.

milaGGL avatar Jun 11 '25 14:06 milaGGL

Hi @milaGGL, here is the repro code I used

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val docRef = Firebase.firestore
            .collection("your-collection-id")
            .document("your-document-id")

        // 🔁 Snapshot listener
        docRef.addSnapshotListener { snapshot, error ->
            if (error != null) {
                Log.e("SnapshotListener", "Listen failed", error)
                return@addSnapshotListener
            }
            Log.d("SnapshotListener", "Received: ${snapshot?.data}")
        }

        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.fillMaxSize()) {
                    TransactionTestButton(docRef)
                }
            }
        }
    }
}

@Composable
fun TransactionTestButton(docRef: DocumentReference) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = {
            val TAG = "TransactionTest"
            var expectedLike: Long = 0

            // 🔄 Read → Write → Read flow
            docRef.get().addOnSuccessListener { initialSnapshot ->
                val currentLike = initialSnapshot.getLong("like") ?: 0
                expectedLike = currentLike + 1

                Firebase.firestore.runTransaction { transaction ->
                    val snapshot = transaction.get(docRef)
                    transaction.update(docRef, "like", expectedLike)
                }.addOnSuccessListener {
                    Log.d(TAG, "Transaction complete. Expecting: $expectedLike")

                    // 🔁 Force read from server
                    docRef.get(Source.SERVER)
                        .addOnSuccessListener { updatedSnapshot ->
                            val actualLike = updatedSnapshot.getLong("like")
                            Log.d(TAG, "Got: $actualLike")
                        }
                        .addOnFailureListener {
                            Log.e(TAG, "Failed to fetch from server", it)
                        }
                }.addOnFailureListener {
                    Log.e(TAG, "Transaction failed", it)
                }
            }.addOnFailureListener {
                Log.e(TAG, "Initial get() failed", it)
            }
        }) {
            Text("Run Transaction Test")
        }
    }
}

You'll notice that expectedLike (the value set in the transaction) is not equal to the value returned by get(Source.SERVER).

SelaseKay avatar Jun 13 '25 15:06 SelaseKay

@milaGGL any luck with reproducing this error with the instructions given above?

rorystephenson avatar Jul 08 '25 14:07 rorystephenson

Experiencing the same issue on iOS using Flutter (I'm aware that this is not the right place for iOS issues but maybe it helps someone).

  • I have a .snapshots() stream active on document users/{uid}.
  • Then my backend writes an update to that document, adding some data fields.
  • Then in my client Flutter app, I am fetching that document up to 20 times using GetOptions(source: Source.server) to ensure I get the update correctly (since streams sometimes act up on iOS). At the same time the stream is still open.

-> The data returned by the server read is outdated and not actually correct. I was looking at the document inside of the Firebase console at the same time and the data in there was different than what the server read returned.

So overall similar setup to what @rorystephenson experienced. Can confirm the issue.

My solution (at least for now it works)

I was able to resolve the issue by refreshing the network connection of the Firestore instance used in the server read:

await firestore.disableNetwork();
await firestore.enableNetwork();

and then performing the server read. Maybe this helps someone out!

nicklbaert avatar Aug 11 '25 19:08 nicklbaert