Section 8: One Person, One Vote - Deep Dive: Votes, Scores and Ranking
Executive Summary: Votes are 86% of all writes and carry the design's one strict invariant: at most one vote per person per post. The vote row is the truth and the score is a cache of it. One transaction on the question's shard reads the existing vote, writes the new one, adjusts the score and appends an outbox event, and the primary key settles every race. Reputation, hot ranking and search boosts follow asynchronously from that event. Ranking then uses three different formulas for three different jobs: score for answers, the Wilson lower bound for comments, and a non-decaying hot score for feeds.
Section 7 ended on the numbers the page deliberately doesn't cache. The interviewer picks it up.
Interviewer: "Ana upvotes Daniel's answer. Walk me through it, and tell me what can go wrong."
You: "Three things must hold. Ana has at most one vote on that answer, ever. The score equals the sum of the vote rows. And Ana sees their own vote immediately, even though other readers see the cached count for up to about two minutes. The first two are invariants, so they live in one transaction. The third is a display rule, so it lives in the response."
The Vote Transaction
The API from Section 4 is set-state: PUT /api/v1/posts/{postId}/vote {value: 1 | -1 | 0}. The server never adds; it compares the new value with the stored one and applies the difference. Section 5 put questions, answers, comments and their votes on one shard, so this is a local transaction. Comment IDs come from the same generator, so comment votes use the same votes table and the same code path; only the counter row differs.
async function setVote(voter: User, postId: bigint, value: 1 | -1 | 0) {
const post = await loadPostOrComment(postId); // routes by the ID's shard bits
if (post.authorId === voter.id) throw http(403, "no self-votes");
if (value === -1 && voter.reputation < 125) throw http(403, "downvote needs 125 rep");
return shardOf(postId).tx(async (db) => {
const row = await db.oneOrNone(`SELECT value FROM votes
WHERE post_id = $1 AND user_id = $2 FOR UPDATE`, [postId, voter.id]);
const old = row?.value ?? 0;
if (old === value) return db.currentScore(postId, value); // retry or double click: delta 0
if (value === 0) await db.exec(`DELETE FROM votes WHERE post_id = $1 AND user_id = $2`, [postId, voter.id]);
else if (old === 0) await db.exec(`INSERT INTO votes (question_id, post_id, user_id, value, created_at)
VALUES ($1, $2, $3, $4, now())`, [post.questionId, postId, voter.id, value]); // PK guards the race
else await db.exec(`UPDATE votes SET value = $3 WHERE post_id = $1 AND user_id = $2`, [postId, voter.id, value]);
const up = (value === 1 ? 1 : 0) - (old === 1 ? 1 : 0);
const down = (value === -1 ? 1 : 0) - (old === -1 ? 1 : 0);
// posts.score/up/down or comments.ups/downs; a question or answer vote also bumps its question's doc_version (Section 9)
const { score, docVersion } = await db.bumpCounters(post, up - down, up, down);
await db.exec(`INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ($1, 'VoteChanged', $2)`,
[post.questionId, { postId, authorId: post.authorId, voterId: voter.id, old, value, docVersion }]);
return { score, myVote: value };
}); // tx() commits, acked by the sync standby (RPO 0); a unique violation retries once
}
A switch from up to down is a delta of −2: the old +1 goes away and the new −1 arrives. The voter's reputation comes from their session, refreshed from the users DB; a few seconds of staleness on the 125 threshold harms nobody.
Interviewer: "Priya has the answer open in two tabs and clicks upvote in both at the same instant. FOR UPDATE locks nothing when there's no row yet."
You: "Right, both transactions read 'no vote' and both try to INSERT. The primary key (post_id, user_id) lets exactly one commit. The other gets a unique violation, and the service retries the whole transaction once. This time SELECT … FOR UPDATE finds the row with value 1, the delta is 0, and it returns the same score. The database enforces the invariant, not my code's timing."
The same transitions as a state machine, one per (voter, post) pair:
Every arrow that repeats the current state is a no-op, which is why a retried PUT can never double count.
Quick check before moving on:
Sign in to continue reading
The rest of this lesson is available with a free account. Signing in with Google or Microsoft is free.
Sign in to read the full lesson