3

In Firebase Realtime Database, it's a pretty common transactional thing that you have

  • "table" A - think of it as "pending"
  • "table" B - think of it as "results"

Some state happens, and you need to "move" an item from A to B.

So, I certainly mean this would likely be a cloud function doing this.

Obviously, this operation has to be atomic and you have to be guarded against racetrack effects and so on.

So, for item 123456, you have to do three things

  • read A/123456/
  • delete A/123456/
  • write the value to B/123456

all atomically, with a lock.

In short what is the Firebase way to achieve this?

  • There's already the awesome ref.transaction system, but I don't think it's relevant here.

  • Perhaps using triggers in a perverted manner?

IDK


Just for anyone googling here, it's worth noting that the mind-boggling new Firestore (it's hard to imagine anything being more mind-boggling than traditional Firebase, but there you have it...), the new Firestore system has built-in .......

enter image description here

This question is about good old traditional Firebase Realtime.

Fattie
  • 27,874
  • 70
  • 431
  • 719

3 Answers3

7

Gustavo's answer allows the update to happen with a single API call, which either complete succeeds or fails. And since it doesn't have to use a transaction, it has much less contention issues. It just loads the value from the key it wants to move, and then writes a single update.

The problem is that somebody might have modified the data in the meantime. So you need to use security rules to catch that situation and reject it. So the recipe becomes:

  1. read the value of the source node
  2. write the value to its new location while deleting the old location in a single update() call
  3. the security rules validate the operation, either accepting or rejecting it
  4. if rejected, the client retries from #1

Doing so essentially reimplements Firebase Database transactions with client-side code and (some admittedly tricky) security rules.

To be able to do this, the update becomes a bit more tricky. Say that we have this structure:

"key1": "value1",
"key2": "value2"

And we want to move value1 from key1 to key3, then Gustavo's approach would send this JSON:

ref.update({
  "key1": null,
  "key3": "value1"
})

When can easily validate this operation with these rules:

".validate": "
    !data.child("key3").exists() && 
    !newData.child("key1").exists() &&
    newData.child("key3").val() === data.child("key1").val()
"

In words:

  • There is currently no value in key3.
  • There is no value in key1 after the update
  • The new value of key3 is the current value of key1

This works great, but unfortunately means that we're hardcoding key1 and key3 in our rules. To prevent hardcoding them, we can add the keys to our update statement:

ref.update({
  _fromKey: "key1",
  _toKey: "key3",
  key1: null,
  key3: "value1"
})

The different is that we added two keys with known names, to indicate the source and destination of the move. Now with this structure we have all the information we need, and we can validate the move with:

".validate": "
    !data.child(newData.child('_toKey').val()).exists() && 
    !newData.child(newData.child('_fromKey').val()).exists() &&
    newData.child(newData.child('_toKey').val()).val() === data.child(newData.child('_fromKey').val()).val()
"

It's a bit longer to read, but each line still means the same as before.

And in the client code we'd do:

function move(from, to) {
  ref.child(from).once("value").then(function(snapshot) {
    var value = snapshot.val();
    updates = {
      _fromKey: from,
      _toKey: to
    };
    updates[from] = null;
    updates[to] = value;
    ref.update(updates).catch(function() {
      // the update failed, wait half a second and try again
      setTimeout(function() {
        move(from, to);
      }, 500);
    });
}
move ("key1", "key3");

If you feel like playing around with the code for these rules, have a look at: https://jsbin.com/munosih/edit?js,console

Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
  • Fascinating , Frank !!!!!!! I guess in a sense you're using the security rule, the `.validate`, to achieve a kind of atomic group; and the general idea is keep retrying until it works. Epic ! – Fattie Mar 01 '18 at 11:39
  • Yup. Keep in mind: the read-update-retry loop is precisely how a transaction also works. We're just doing it in our own code here, instead of in the SDK+database server. The only extra complication here is that we have to indicate the source and destination in known property names, since rules can't iterate over the data you post. – Frank van Puffelen Mar 01 '18 at 14:05
  • Just BTW, we've found an incredibly useful idea is to have little independent systems (say, a little Swift-Kitura-Sql service running somewhere) that interacts w/ FBase: for doing things that FBase "isn't really meant for". Then, you can use the **mindnumbing** power of FBase as an OCC layer between all the apps and the Firebase, handily eliminating half a dozen staff, and your FBase cloud code just calls to those services for those concepts where needed. I've often wondered if a further amazing extension to FBase would be a sort of tightly coupled, EZ-to-provision, thing like that. – Fattie Mar 01 '18 at 14:23
  • (So, sort of a "tightly coupled microservices". So in the left column I'd press a button and get a typical instance, with the usual SQL, Neo4J or whatever I wish, as usual on gcp or aws. But in some way it would be instantly coupled to the Firebase Functions - ie, you wouldn't have to use http and an api.) There's a catchphrase, tightly coupled microservices – Fattie Mar 01 '18 at 14:27
  • Isn't that what Cloud Functions for Firebase are? :-) – Frank van Puffelen Mar 01 '18 at 14:53
  • heh, by all means! life is FBase Cloud functions. but IMO on any large project you have some separate service(s) for stuff that Firebase is not "ideal" for. (random examples.. currencies, tedious stuff that deals extensively with other services, processors, and any relational-like subsystems etc). In those cases (I guess?) the best way to hook up is an API over HTTP. Anyway this technique is epic. – Fattie Mar 01 '18 at 16:29
0

Firebase works with Dictionaries, a.k.a, key-value pair. And to change data in more than one table on the same transaction you can get the base reference, with a dictionary containing "all the instructions", for instance in Swift:

let reference = Database.database().reference() // base reference

let tableADict = ["TableA/SomeID" : NSNull()] // value that will be deleted on table A
let tableBDict = ["TableB/SomeID" : true] // value that will be appended on table B, instead of true you can put another dictionary, containing your values

You should then merge (how to do it here: How do you add a Dictionary of items into another Dictionary) both dictionaries into one, lets call it finalDict, then you can update those values, and both tables will be updated, deleting from A and "moving to" B

reference.updateChildValues(finalDict) // update everything on the same time with only one transaction, w/o having to wait for one callback to update another table
Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
Gustavo Vollbrecht
  • 3,188
  • 2
  • 19
  • 37
  • ciao @Gustavo, thanks, what do you mean "You should then merge both dictionaries into one" ............. – Fattie Feb 28 '18 at 15:56
  • creating one dictionary, with a structure that looks like this -> [(key: "table A/path/", value: NSNull()),(key : "tableB/path/", YourValue)] – Gustavo Vollbrecht Feb 28 '18 at 15:59
  • literally creating one dictionary, from both "instructions", so you can execute them on the same moment, check the answer edit – Gustavo Vollbrecht Feb 28 '18 at 16:00
  • hmm, but the read has to also be atomic :O (see edit to Q) – Fattie Feb 28 '18 at 16:02
  • since you will only be updating once, both will happen at the same time, this is what I know by atomic, because one operation won't need to wait for the other to complete – Gustavo Vollbrecht Feb 28 '18 at 16:03
  • You can read them in different moments, then you update them together. 1. Read A / 2. Read B / 3. Delete A and Update B on the same moment, those should be your three instructions – Gustavo Vollbrecht Feb 28 '18 at 16:05
  • I don't think it's possible to read from more than one table atomically like you described on your Q – Gustavo Vollbrecht Feb 28 '18 at 16:09
  • intriguing @gustavo : maybe it's not possible to do such a lock? I was thinking maybe using triggers in some way ??? – Fattie Feb 28 '18 at 16:14
  • I haven't found a way to read from more than one table atomically, and we still need to update tables atomically, of course you could try to do some sort of semaphore but wouldn't be 100% safe if more than one user tried to do everything on the same moment. – Gustavo Vollbrecht Feb 28 '18 at 16:18
0

There are no "tables" in Realtime Database, so I'll use the term "location" instead to refer to a path that contains some child nodes.

Realtime Database provides no way to atomically transaction on two different locations. When you perform a transaction, you have to choose a single location, and you may only make changes under that single location.

You might think that you could just transact at the root of the database. This is possible, but those transactions may fail in the face of concurrent non-transaction write operations anywhere within the database. It's a requirement that there must be no non-transactional writes anywhere at the location where transactions take place. In other words, if you want to transact at a location, all clients must be transacting there, and no clients may write there without a transaction.

This rule is certainly going to be problematic if you transact at the root of your database, where clients are probably writing data all over the place without transactions. So, if you want perform an atomic "move", you'll either have to make all your clients use transactions all the time at the common root location for the move, or accept that you can't do this truly atomically.

Doug Stevenson
  • 297,357
  • 32
  • 422
  • 441
  • Doug, that sounds like the decisive answer. As always, you guys rock. – Fattie Feb 28 '18 at 21:10
  • I'm wondering if there's a trick way to do it in effect using Triggers? Essentially, you'd have a Function which is triggered on a delete in A. IDK. – Fattie Feb 28 '18 at 21:11
  • Cloud Functions is not any more atomic than any other client code. There's never a "trick" to make something atomic if the underlying platform doesn't support it. – Doug Stevenson Feb 28 '18 at 21:19
  • Hmm; I think the trigger system is in fact atomic; it will run once and only once. (I think - maybe that's not decisive.) – Fattie Feb 28 '18 at 21:32
  • Guaranteed once execution is not at all the same thing as atomicity. "Atomic" means that all of the changes will be commited at the same time, all or none. Each triggered Cloud Function is simply another client writing to the database like any other client. It's not "privileged" at all in terms of atomicity. – Doug Stevenson Feb 28 '18 at 21:35
  • I actually prefer Gustavo's answer, which combines the "delete one and write the other" into a single operation. You could even use [server-side security rules](https://firebase.google.com/docs/database/security/) to enforce this type of update by comparing `newData` and `data`. – Frank van Puffelen Mar 01 '18 at 00:50
  • @FrankvanPuffelen So how do you read-then-write atomically, as the question stated? That doesn't appear to be what he's doing. – Doug Stevenson Mar 01 '18 at 00:56
  • You'd have to read the current version a prio, then validate the total (old and new) state in security rules, and then of course retry if the server rejects. That is essentially what transactions do. It leads to complex security rules for sure, but given the completeness of the rules language it's almost certainly possible. The furthest along I got at some point was in this answer: https://stackoverflow.com/a/37956590. I'm not sure how complex it is here btw: you should mostly validate that the new data in the new location matches the old data in the old location. – Frank van Puffelen Mar 01 '18 at 02:31
  • OK. It was definitely more involved than I expected, and it requires some more effort to send the multi-location update, but I got it working and pretty secured as far as I can see. I wrote it up in a separate answer, since it became rather long: https://stackoverflow.com/a/49042980 – Frank van Puffelen Mar 01 '18 at 05:11