Per-identifier locking for the blocking queue rate limiter - #2709
Conversation
462ed60 to
be0c03a
Compare
| uint64_t BedrockBlockingCommandQueue::_now() const | ||
| { | ||
| const string identifier = command->blockingQueueRateLimitIdentifier; | ||
| return STimeNow(); | ||
| } |
There was a problem hiding this comment.
This is here so we can implement automated tests
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f8eca1c02
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Hot path: called by push() and by _dequeue() under the base `_queueMutex`. Keep it O(1) by reading only | ||
| // the precomputed block deadline. The windowed time is summed in recordExecutionTime, off the blocking thread. | ||
| const uint64_t now = _now(); | ||
| return _isBlocked(_accountStates, accountID, now) || _isBlocked(_commandStates, commandName, now); |
There was a problem hiding this comment.
Respect disabled thresholds when checking active blocks
When a control command sets AccountThresholdMs or CommandThresholdMs to 0 after an identifier has already been blocked, new traffic for that account/command is still rejected until the old blockedUntil deadline because this check only consults the cached block state and not the current threshold. That contradicts the new “threshold of 0 disables that dimension” behavior and makes the documented emergency disable/reset path ineffective unless operators also know to send ClearBlocks.
Useful? React with 👍 / 👎.
…only branch, blocked-identifier status, MS naming
| response["previousMaxBlockingQueueTimePerIdentifierMs"] = to_string(previousUS / 1000); | ||
| SINFO("Setting blocking queue max time per identifier to " << maxTimeMs << "ms"); | ||
| if (command->request.isSet("WindowMS")) { | ||
| int64_t windowMS = command->request.calc64("WindowMS"); |
There was a problem hiding this comment.
You can collapse the above two lines into just int64_t windowMS = command->request.calc64("WindowMS");
it will end up 0 if it's not set. Similar below.
There was a problem hiding this comment.
I don't think we want that. If you pass identifierThresholdMS or commandThresholdMS as 0, that means we're not gonna check for those anymore, which means disabling the rate limit. I think we want to allow disabling it, so having the check if the property exists makes sense.
| // locking each entry to read `blockedUntil`; no code path takes an entry lock before the map mutex, so this | ||
| // can't deadlock. | ||
| auto inspect = [now](StateMap& map, size_t& tracked, list<string>& blocked) { | ||
| lock_guard<decltype(map.mapMutex)> lock(map.mapMutex); |
There was a problem hiding this comment.
I don't really like this lock wrapping a full walk of the entire map, even more so since it waits for a lock on every single item in the map. It seems like it's only used in Status so maybe performance wise it's not that big of a deal.
However, the two locks are worth considering. Are you sure there's no way to cause a deadlock with this?
There was a problem hiding this comment.
I don't see how we would cause a deadlock here... maybe you're seeing something I'm not seeing?
There was a problem hiding this comment.
getState only uses the mapMutex, same as clearRateLimits. The other places always use mapMutex, then the state mutex in that order, so I don't think a deadlock would happen.
| void BedrockBlockingCommandQueue::push(unique_ptr<BedrockCommand>&& command) | ||
| { | ||
| if (isBlocked(command->blockingQueueRateLimitIdentifier, command->request.methodLine)) { | ||
| STHROW("503 Blocking queue rate limited (time)"); |
There was a problem hiding this comment.
NAB: Auth uses 429 Too Many Requests for something very similar, maybe we should standardize these.
| @@ -2097,12 +2099,36 @@ void BedrockServer::_control(unique_ptr<BedrockCommand>& command) | |||
| } else if (SIEquals(command->request.methodLine, "SetConflictPageLocks")) { | |||
| _enableConflictPageLocks = command->request.test("enable"); | |||
| } else if (SIEquals(command->request.methodLine, "SetBlockingQueueTimeRateLimit")) { | |||
There was a problem hiding this comment.
Please add this to Step 4 of https://stackoverflowteams.com/c/expensify/questions/17450/17451#17451
There was a problem hiding this comment.
Will do once we approve this and there are not more changes
| it = state->commands.erase(it); | ||
| continue; | ||
| } | ||
| total += min(it->elapsedTime, windowUS - age); |
There was a problem hiding this comment.
I don't understand this. Why not total += it->elapsedTime?
There was a problem hiding this comment.
added a comment
There was a problem hiding this comment.
let me know if it's clearer or not
Co-authored-by: Florent De'Neve <florent@expensify.com>
Takes a `commitCount` and returns the journal hash for that commit. Reads through the DB pool the same way the Status command does, so it doesn't need the sync node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers argument validation, that the returned hash matches what's actually in the journal, and that the command is allowed from a non-localhost source while other control commands aren't. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
We'll verify this one by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Florent De'Neve <florent@expensify.com>
Details
This PR changes how we handle the blocking commit thread rate limits.
The logic we applied here is:
Every time a command finishes in the blocking queue, we will:
The StateMap object has a mutex that is briefly held while we retrieve an entry from the map.
The entry in the map is an IdentifierState, which also has a mutex, a blockedUntil property (so the checkes when we're inserting the commands to the blocking queue is O(1)) and a list of <startTime, timeSpent> for commands of that identifier, which we'll use to calculate how long they spent in the blocking queue in the last 180s every time a command is finished.
So the max we'll hold a mutex for someone is if they have several commands going to the blocking queue, or if we have the same command going to the blocking queue too much. But if that happens, there's a high chance we will block them either way.
Fixed Issues
For https://github.com/Expensify/Expensify/issues/662853
Tests
Compiles clean; existing unit + cluster tests unaffected (no behavior change yet).
Internal Testing Reminder: when changing bedrock, please compile auth against your new changes