Description
[REQUIRED] Describe your environment
- Operating System version: MacOS 10.15.7
- Browser version: React Native (0.64.3)
- Firebase SDK version: 9.6.5
- Firebase Product: database
[REQUIRED] Describe the problem
Queries to the same realtime database path get mixed up and return incorrect results depending on the order in which you define queries, execute get
requests, and trigger onChild
listener callbacks. In developing an app that makes both get
requests and that have onChild
callbacks triggered on the same realtime database path, I found that depending on the order in which database references and queries are defined and executed, you get unexpected behavior and incorrect results.
It appears that if a database query is defined while another query or reference is being executed (either via a get
or via an onChild
listener returning results), the newly defined query somehow overwrites other references or queries that were defined, causing both the currently executing query or onChild
listener to return incorrect results and subsequent queries to the same path to return incorrect (or no) results.
This means as a developer you have to ensure that only one query can ever be executing at any given time against a database path. This is extremely problematic or near impossible to ensure in an app that involves collaboration between multiple users across different instances/apps.
This issue was observed on v9. I haven't tried reverting to v8 to see if the issue exists there as well.
Steps to reproduce:
Step 1. Create realtime database with sample data and rules
Create a database (or use an existing one). At the database path /todos
, import the following sample data.
{
"-MnRQNp76jfDkd25KlS5" : {
"isCompleted" : false,
"text" : "Todo 1"
},
"-MvPbrl8o_Afb0MdeqJb" : {
"isCompleted" : false,
"text" : "Todo 2"
},
"-MvPc1CNYHYsHOGKFGqR" : {
"completedAt" : 1644345435041,
"isCompleted" : true,
"text" : "Todo 3 - completed"
},
"-MvPdfdB5nyYPo1rczp9" : {
"isCompleted" : false,
"text" : "Todo 4"
},
"-MvPdidjFC4oXqHsvrRG" : {
"completedAt" : 1644345879235,
"isCompleted" : true,
"text" : "Todo 5 - completed"
},
"-MvQ06v44sbWMLMHBQT4" : {
"isCompleted" : false,
"text" : "Todo 6"
}
}
Create rules that allow reads of the path /todos
and an index on the property isCompleted
:
{
"rules": {
"todos": {
".read": true,
".write": false,
".indexOn": ["isCompleted"]
}
}
}
Step 2. Create test that performs both a get and onChild subscription
The following code creates one database reference and two queries. The database reference is used for the onChild
subscriptions and the queries are used to perform get
s with two different filter criteria (i.e. one that gets open todos and another that gets closed todos).
Create a test that calls the createErrorAsync
function.
import { initializeApp } from 'firebase/app';
import {
getDatabase,
ref,
onChildAdded,
onChildChanged,
onChildRemoved,
get,
query,
orderByChild,
equalTo,
} from 'firebase/database';
const FIREBASE_CONFIG = {
// firebase config goes here ...
};
/*
If you create multiple references or queries before executing them with a
get or listener, they seem to be combined somehow, which is NOT as expected.
*/
export const createErrorAsync = async () => {
// connect
const app = await initializeApp(FIREBASE_CONFIG);
// database path
const dbRefPath = 'todos';
/*
Here we'll create three separate queries / database references that will
be used to fetch or subscribe to the same path in the database.
*/
// create reference/query 1 --> used to get open todos
const dbRef1 = query(
ref(getDatabase(app), dbRefPath),
orderByChild('isCompleted'),
equalTo(false),
);
// create reference/query 2 --> used to listen for changes
const dbRef2 = ref(getDatabase(app), dbRefPath);
// create reference/query 3 --> used to get closed todos
const dbRef3 = query(
ref(getDatabase(app), dbRefPath),
orderByChild('isCompleted'),
equalTo(true),
);
// create listeners on reference 2
onChildAdded(dbRef2, (snap) => {
console.log('dbRef2 - ADDED -', snap.key);
});
onChildChanged(dbRef2, (snap) => {
console.log('dbRef2 - CHANGED -', snap.key);
});
onChildRemoved(dbRef2, (snap) => {
console.log('dbRef2 - REMOVED -', snap.key);
});
// execute get on reference 1
const snap1 = await get(dbRef1);
// observe data is returned
console.log('dbRef1', snap1.val());
// execute get on reference 3
const snap3 = await get(dbRef3);
// observe data is returned
console.log('dbRef3', snap3.val());
}
Expected Results:
After running, I would expect the following output when the createErrorAsync
function is called. The onChild
callbacks should return all six entities, the first get
should return four entities, and the second get
should return two entities.
dbRef1 Object {
"-MnRQNp76jfDkd25KlS5": Object {
"isCompleted": false,
"text": "Todo 1",
},
"-MvPbrl8o_Afb0MdeqJb": Object {
"isCompleted": false,
"text": "Todo 2",
},
"-MvPdfdB5nyYPo1rczp9": Object {
"isCompleted": false,
"text": "Todo 4",
},
"-MvQ06v44sbWMLMHBQT4": Object {
"isCompleted": false,
"text": "Todo 6",
},
}
dbRef2 - ADDED - -MnRQNp76jfDkd25KlS5
dbRef2 - ADDED - -MvPbrl8o_Afb0MdeqJb
dbRef2 - ADDED - -MvPc1CNYHYsHOGKFGqR
dbRef2 - ADDED - -MvPdfdB5nyYPo1rczp9
dbRef2 - ADDED - -MvPdidjFC4oXqHsvrRG
dbRef2 - ADDED - -MvQ06v44sbWMLMHBQT4
dbRef3 Object {
"-MvPc1CNYHYsHOGKFGqR": Object {
"completedAt": 1644345435041,
"isCompleted": true,
"text": "Todo 3 - completed",
},
"-MvPdidjFC4oXqHsvrRG": Object {
"completedAt": 1644345879235,
"isCompleted": true,
"text": "Todo 5 - completed",
},
}
Actual Results:
What is actually returned is that the onChild
listeners trigger six onChildAdded
callbacks followed by two onChildRemoved
callbacks (when nothing was removed). Then the four entities are returned by the first get
request. And then, the final get
returns no results at all.
dbRef2 - ADDED - -MnRQNp76jfDkd25KlS5
dbRef2 - ADDED - -MvPbrl8o_Afb0MdeqJb
dbRef2 - ADDED - -MvPc1CNYHYsHOGKFGqR
dbRef2 - ADDED - -MvPdfdB5nyYPo1rczp9
dbRef2 - ADDED - -MvPdidjFC4oXqHsvrRG
dbRef2 - ADDED - -MvQ06v44sbWMLMHBQT4
dbRef2 - REMOVED - -MvPc1CNYHYsHOGKFGqR
dbRef2 - REMOVED - -MvPdidjFC4oXqHsvrRG
dbRef1 Object {
"-MnRQNp76jfDkd25KlS5": Object {
"isCompleted": false,
"text": "Todo 1",
},
"-MvPbrl8o_Afb0MdeqJb": Object {
"isCompleted": false,
"text": "Todo 2",
},
"-MvPdfdB5nyYPo1rczp9": Object {
"isCompleted": false,
"text": "Todo 4",
},
"-MvQ06v44sbWMLMHBQT4": Object {
"isCompleted": false,
"text": "Todo 6",
},
}
dbRef3 null
Partial Workaround:
Through a lot of trial-and-error, I discovered a partial workaround for the issue. You basically need to try (as best you can) to ensure the queries are never executed at the same time. As I mentioned in the earlier description, it seems pretty much impossible to ensure this in a multi-user app where there can be database mutations made by anyone that can trigger onChild
callbacks at any time.
If you modify the above code sample to define the queries / database references in a different order and implement a timeout/delay to ensure the onChild
callbacks have finished executing, you seem to get the results you expect. I have no idea how robust this workaround is though and would much rather have someone from the Firebase team comment.
Here's the modified code for reference:
import { initializeApp } from 'firebase/app';
import {
getDatabase,
ref,
onChildAdded,
onChildChanged,
onChildRemoved,
get,
query,
orderByChild,
equalTo,
} from 'firebase/database';
const FIREBASE_CONFIG = {
// firebase config goes here
};
/*
When you create a database reference or query and then execute it
immediately and wait for the result before doing anything else,
things seem to work as expected.
*/
export const errorWorkAroundAsync = async () => {
// connect
const app = await initializeApp(FIREBASE_CONFIG);
// database path
const dbRefPath = 'todos';
// create reference/query 1
const dbRef1 = query(
ref(getDatabase(app), dbRefPath),
orderByChild('isCompleted'),
equalTo(false),
);
// execute get on reference 1
const snap1 = await get(dbRef1);
// observe data is returned
console.log('dbRef1', snap1.val());
// create reference/query 2
const dbRef2 = ref(getDatabase(app), dbRefPath);
// create listeners on reference 2
onChildAdded(dbRef2, (snap) => {
console.log('dbRef2 - ADDED -', snap.key);
});
onChildChanged(dbRef2, (snap) => {
console.log('dbRef2 - CHANGED -', snap.key);
});
onChildRemoved(dbRef2, (snap) => {
console.log('dbRef2 - REMOVED -', snap.key);
});
/*
Here I'm setting a delay to let the listener complete. If you don't
do this, this third query is somehow combined/mixed with the above
two queries, causing the listener to return strange results. Specfically,
it returns both ADDED events and REMOVED events (when nothing was removed).
If you remove this timeout, you can see this occur.
*/
await new Promise(resolve => setTimeout(resolve, 10000));
// create reference/query 3
const dbRef3 = query(
ref(getDatabase(app), dbRefPath),
orderByChild('isCompleted'),
equalTo(true),
);
// execute get on reference 3
const snap3 = await get(dbRef3);
// observe data is returned
console.log('dbRef3', snap3.val());
}
If you remove the delay, you can see how the queries get mixed up and change the behavior of the get
and onChild
listeners. After removing the delay, the onChild
listeners return six onChildAdded
callbacks and then four onChildRemoved
callbacks (when nothing was removed).
dbRef1 Object {
"-MnRQNp76jfDkd25KlS5": Object {
"isCompleted": false,
"text": "Todo 1",
},
"-MvPbrl8o_Afb0MdeqJb": Object {
"isCompleted": false,
"text": "Todo 2",
},
"-MvPdfdB5nyYPo1rczp9": Object {
"isCompleted": false,
"text": "Todo 4",
},
"-MvQ06v44sbWMLMHBQT4": Object {
"isCompleted": false,
"text": "Todo 6",
},
}
dbRef2 - ADDED - -MnRQNp76jfDkd25KlS5
dbRef2 - ADDED - -MvPbrl8o_Afb0MdeqJb
dbRef2 - ADDED - -MvPc1CNYHYsHOGKFGqR
dbRef2 - ADDED - -MvPdfdB5nyYPo1rczp9
dbRef2 - ADDED - -MvPdidjFC4oXqHsvrRG
dbRef2 - ADDED - -MvQ06v44sbWMLMHBQT4
dbRef2 - REMOVED - -MnRQNp76jfDkd25KlS5
dbRef2 - REMOVED - -MvPbrl8o_Afb0MdeqJb
dbRef2 - REMOVED - -MvPdfdB5nyYPo1rczp9
dbRef2 - REMOVED - -MvQ06v44sbWMLMHBQT4
dbRef3 Object {
"-MvPc1CNYHYsHOGKFGqR": Object {
"completedAt": 1644345435041,
"isCompleted": true,
"text": "Todo 3 - completed",
},
"-MvPdidjFC4oXqHsvrRG": Object {
"completedAt": 1644345879235,
"isCompleted": true,
"text": "Todo 5 - completed",
},
}