Pregunta

I'm building a real-time poll in Firebase. Each vote is stored in a list field. To prevent having to pull every vote down to the client in order to count them, I cache the tallies for each choice in counter fields.

poll1
    counts
        choice1: 5
        choice2: 2
        choice3: 10
        choice4: 252
    voters
        uid1 : choice1
        uid6 : choice3
        uid25: choice2
        uid31: choice1

I'm currently updating the counter with the following transaction:

var fireCountPush = new Firebase(self.fireCountUrl+node+id);
fireCountPush.transaction(function(current_value) {
    return current_value + 1;
}, function(error, committed, snapshot) {
    if(committed) {
        var fireVote = new Firebase(self.fireVotesUrl);
        fireVote.child(self.user.uid).set(id);
    }
});

However, I want to atomically add the user to the voters list, ideally in the same transaction. Unfortunately, the way I have it now, I'm having to add the user after the transaction commits successfully. This is a huge security issue since it can be easily disabled in the browser by editing the script.

Is there any way to both update the counter and add a user to the list of voters without having to download the entire object in the transaction?

¿Fue útil?

Solución

The simplest answer here is to have a neutral party (a server script) monitor the list of voters and increment the counter. Then one only need make sure users add themselves by their uid and can only do so once.

I'm sure there are also some brilliant ways to do this entirely with security rules. I'm, unfortunately, not that brilliant, but here's a brute force approach you can improve on if you really want the pain of a client-only solution.

The plan:

  • force the user to write an audit record first
  • force them to add their name to the list of voters second
  • allow them to update the counter when both of these records exist and match the vote number

The schema:

/audit/<$x>/<$user_id>
/voters/$user_id/<$x>
/total/<$x>

We prevent user from modifying audit/ if they have already voted (voters/$user_id exists), or if the audit record already exists (someone has already claimed that count), or if the vote is not incremented by exactly one:

"audit": {
  "$x": {
     ".write": "newData.exists() && !data.exists()", // no delete, no overwrite
     ".validate": "!root.child('voters/'+auth.uid).exists() && $x === root.child('total')+1"
  }
}

You would update audit in a transaction, essentially trying to "claim" each increment until successful and cancelling the transaction (by returning undefined) any time the record to be added is not null (someone has already claimed it). This gives you your unique vote number.

To prevent any funny business, we store a list of voters, which forces each voter to only write into audit/ once. I can only write to voters if I've never voted before and only if an audit record has already been created with my unique vote number:

"voters": {
  "$user_id": {
     ".write": "newData.exists() && !data.exists()", // no delete, no replace
     ".validate": "newData.isNumber() && root.child('audit/'+newData.val()).val() === $user_id"
  }
}

Last, but not least, we update the counter to match our claimed vote id. It must match my vote number and it may only increase. This prevents a race condition where one user creates an audit record and voters record, but someone else has already increased the total before I finished my three steps.

"total": {
  ".write": "newData.exists()", // no delete
  ".validate": "newData.isNumber() && newData.val() === root.child('audit/'+auth.uid).val() && newData.val() > data.val()"
}

Updating the total, like adding the initial audit record, would be done in a transaction. If the current value of the total is greater than my assigned vote number, then I just cancel the transaction with undefined because someone else has already voted after me and updated it higher. Not a problem.

Otros consejos

Following on from my second comment - why keep a separate count?

Keep a count in the UniqueId Record that you push (increment for each new record) - and instead of watching for changes to the count, watch for 'add_child' on your UniqeID record with a limit of 1. This will return the current total.

/userId/=count

Job done - in one update.

edit

If you are still mulling it over... I'll give you a design pattern to mitigate its lack of atomicity...

'Spray and Stencil Update'

We have validation that can't drive updates, but knows what the update should be (the count should go up by one and only one) - this is the passive stencil, just filtering out what shouldn't go through.

We have a client which can drive updates, but can't be quite sure what the update should be. It wants to update the count by one, but the count could have incremented since it last read it! This would fail the validation and it will have to try again, but the same could happen and we have a race condition...

Trying one update and checking the result is like using a fine brush with a sencil it is redundant. So lets make the update into a 'spray' in a loop, no need to check the results, just call the update - attempt to update the count by 1, 2, 3, 4, 5 ,6... One of these will be correct and update! The others will fail either because the user has already voted (a previous update worked), or because the index is wrong! As long as the size of the spray (number of attempted writes) is larger than the number of other voters between reading the last index and spraying the updates you are 100% sure to succeed.

Old school purists wont like this idea, but they prever SVN to GIT, and will be using SQL not firebase :)

Licenciado bajo: CC-BY-SA con atribución
No afiliado a StackOverflow
scroll top