-
Notifications
You must be signed in to change notification settings - Fork 564
fix: Cap WorkerLock timeout intervals to 60 seconds
#19394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
reivilibre
merged 29 commits into
element-hq:develop
from
famedly:jason/cap-worker-locks
May 5, 2026
+216
−32
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
ff23d00
max() and min() were probably switched. Set max to arbitrary 15 minut…
jason-famedly 548c85b
changelog
jason-famedly 1d22f90
Update changelog.d/19394.bugfix
jason-famedly e864cfe
Adjust for the retry interval actually being a timeout interval, and …
jason-famedly d416dc8
unecessarily long pump() in test, left over from testing logging
jason-famedly e555cd6
adjust changelog(again)
jason-famedly 548ccc1
Merge branch 'develop' into jason/cap-worker-locks
jason-famedly e0d2882
apply some feedback
jason-famedly 9db70d2
Merge branch 'develop' into jason/cap-worker-locks
jason-famedly 7b1ab26
lint
jason-famedly 5637246
reword comment and adjust logging warning to contain more info
jason-famedly 06ffc93
Merge branch 'develop' into jason/cap-worker-locks
jason-famedly 3a77b0d
rename _next to next_interval
jason-famedly c734d9f
restore worker model test original
jason-famedly e281f98
missed a next being renamed
jason-famedly 680c49a
restore comment
jason-famedly 5c60cf4
stop logging a warning when calculating a timeout interval and start …
jason-famedly 74f69d5
adjust timeout tests
jason-famedly 3538626
start capping timeout for retry at 60 seconds
jason-famedly 4712697
Merge branch 'develop' into jason/cap-worker-locks
jason-famedly 701adc8
Update synapse/handlers/worker_lock.py
jason-famedly 5d7d5f2
follow up on renaming variable suggestion
jason-famedly 42b6050
change constants for retry intervals into basic Durations
jason-famedly b70da34
move warning to TimeoutError exception block(and linting too I guess)
jason-famedly e12bb08
adjust tests
jason-famedly 36848ce
Update changelog.d/19394.bugfix
jason-famedly d8a1c09
feedback
jason-famedly 90402c8
correct kwarg that was missing
jason-famedly a6d5b68
lint
jason-famedly File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Capped the `WorkerLock` time out interval to a maximum of 60 seconds to prevent dealing with excessively long numbers. Contributed by Famedly. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,9 @@ | |
| from twisted.internet.testing import MemoryReactor | ||
|
|
||
| from synapse.server import HomeServer | ||
| from synapse.storage.databases.main.lock import _RENEWAL_INTERVAL | ||
| from synapse.util.clock import Clock | ||
| from synapse.util.duration import Duration | ||
|
|
||
| from tests import unittest | ||
| from tests.replication._base import BaseMultiWorkerStreamTestCase | ||
|
|
@@ -40,6 +42,7 @@ def prepare( | |
| self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer | ||
| ) -> None: | ||
| self.worker_lock_handler = self.hs.get_worker_locks_handler() | ||
| self.store = self.hs.get_datastores().main | ||
|
|
||
| def test_wait_for_lock_locally(self) -> None: | ||
| """Test waiting for a lock on a single worker""" | ||
|
|
@@ -56,6 +59,66 @@ def test_wait_for_lock_locally(self) -> None: | |
| self.get_success(d2) | ||
| self.get_success(lock2.__aexit__(None, None, None)) | ||
|
|
||
| def test_timeouts_for_lock_locally(self) -> None: | ||
| """ | ||
| Test that we regularly retry to reacquire locks. | ||
|
|
||
| This is a regression test to make sure the lock retry time doesn't balloon to a value | ||
| so large it can't even be printed reliably anymore. | ||
| """ | ||
|
|
||
| # Create and acquire the first lock | ||
| lock1 = self.worker_lock_handler.acquire_lock("name", "key") | ||
| self.get_success(lock1.__aenter__()) | ||
|
|
||
| # Create and try to acquire the second lock | ||
| lock2 = self.worker_lock_handler.acquire_lock("name", "key") | ||
| d2 = defer.ensureDeferred(lock2.__aenter__()) | ||
| # Make sure we haven't acquired the lock yet (`lock1` still holds it) | ||
| self.assertNoResult(d2) | ||
|
|
||
| # Advance time by an hour (some duration that would previously cause our timeout | ||
| # to balloon if it weren't constrained). Max back-off (saturate) | ||
| # | ||
| # Note: We use `_pump_by` instead of `pump`/`advance` as the `Lock` has an | ||
| # internal background looping call that runs every 30 seconds | ||
| # (`_RENEWAL_INTERVAL`) to renew the `Lock` and push it's "drop timeout" value | ||
| # further out by 2 minutes (`_LOCK_TIMEOUT_MS`). The `Lock` will prematurely | ||
| # drop if this renewal is not allowed to run, which sours the test. | ||
| # self.pump(amount=Duration(hours=1)) | ||
| self._pump_by(amount=Duration(hours=1), by=_RENEWAL_INTERVAL) | ||
|
|
||
| # Make sure we haven't acquired the `lock2` yet (`lock1` still holds it) | ||
| self.assertNoResult(d2) | ||
|
|
||
| # Release the first lock (`lock1`). The second lock(`lock2`) should be | ||
| # automatically acquired by the `pump()` inside `get_success()` | ||
| self.get_success(lock1.__aexit__(None, None, None)) | ||
|
|
||
| # We should now have the lock | ||
| self.successResultOf(d2) | ||
|
|
||
| def _pump_by( | ||
| self, | ||
| *, | ||
| amount: Duration = Duration(seconds=0), | ||
| by: Duration = Duration(seconds=0.1), | ||
| ) -> None: | ||
| """ | ||
| Like `self.pump()` but you can specify the time increment to advance with until | ||
| you reach the time amount. | ||
|
|
||
| Unlike `self.pump()`, this doesn't multiply the time at all. | ||
|
|
||
| Args: | ||
| amount: The amount of time to advance | ||
| by: The time increment in seconds to advance time by until we reach the `amount` | ||
| """ | ||
| end_time_s = self.reactor.seconds() + amount.as_secs() | ||
|
|
||
| while self.reactor.seconds() < end_time_s: | ||
| self.reactor.advance(by.as_secs()) | ||
|
Comment on lines
+119
to
+120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to note for people who might come back to look at this PR, the implementation I recommended here isn't quite precise as it may advance time by one |
||
|
|
||
| def test_lock_contention(self) -> None: | ||
| """Test lock contention when a lot of locks wait on a single worker""" | ||
| nb_locks_to_test = 500 | ||
|
|
@@ -124,3 +187,70 @@ def test_wait_for_lock_worker(self) -> None: | |
|
|
||
| self.get_success(d2) | ||
| self.get_success(lock2.__aexit__(None, None, None)) | ||
|
|
||
| def test_timeouts_for_lock_worker(self) -> None: | ||
| """ | ||
| Test that we regularly retry to reacquire locks. | ||
|
|
||
| This is a regression test to make sure the lock retry time doesn't balloon to a value | ||
| so large it can't even be printed reliably anymore. | ||
| """ | ||
| worker = self.make_worker_hs( | ||
| "synapse.app.generic_worker", | ||
| extra_config={ | ||
| "redis": {"enabled": True}, | ||
| }, | ||
| ) | ||
| worker_lock_handler = worker.get_worker_locks_handler() | ||
|
|
||
| # Create and acquire the first lock on the main process | ||
| lock1 = self.main_worker_lock_handler.acquire_lock("name", "key") | ||
| self.get_success(lock1.__aenter__()) | ||
|
|
||
| # Create and try to acquire the second lock on the worker | ||
| lock2 = worker_lock_handler.acquire_lock("name", "key") | ||
| d2 = defer.ensureDeferred(lock2.__aenter__()) | ||
| # Make sure we haven't acquired the lock yet (`lock1` still holds it) | ||
| self.assertNoResult(d2) | ||
|
|
||
| # Advance time by an hour (some duration that would previously cause our timeout | ||
| # to balloon if it weren't constrained). Max back-off (saturate) | ||
| # | ||
| # Note: We use `_pump_by` instead of `pump`/`advance` as the `Lock` has an | ||
| # internal background looping call that runs every 30 seconds | ||
| # (`_RENEWAL_INTERVAL`) to renew the `Lock` and push it's "drop timeout" value | ||
| # further out by 2 minutes (`_LOCK_TIMEOUT_MS`). The `Lock` will prematurely | ||
| # drop if this renewal is not allowed to run, which sours the test. | ||
| # self.pump(amount=Duration(hours=1)) | ||
| self._pump_by(amount=Duration(hours=1), by=_RENEWAL_INTERVAL) | ||
|
|
||
| # Make sure we haven't acquired the `lock2` yet (`lock1` still holds it) | ||
| self.assertNoResult(d2) | ||
|
|
||
| # Release the first lock (`lock1`). The second lock(`lock2`) should be | ||
| # automatically acquired by the `pump()` inside `get_success()` | ||
| self.get_success(lock1.__aexit__(None, None, None)) | ||
|
|
||
| # We should now have the lock | ||
| self.successResultOf(d2) | ||
|
|
||
| def _pump_by( | ||
| self, | ||
| *, | ||
| amount: Duration = Duration(seconds=0), | ||
| by: Duration = Duration(seconds=0.1), | ||
| ) -> None: | ||
| """ | ||
| Like `self.pump()` but you can specify the time increment to advance with until | ||
| you reach the time amount. | ||
|
|
||
| Unlike `self.pump()`, this doesn't multiply the time at all. | ||
|
|
||
| Args: | ||
| amount: The amount of time to advance | ||
| by: The time increment in seconds to advance time by until we reach the `amount` | ||
| """ | ||
| end_time_s = self.reactor.seconds() + amount.as_secs() | ||
|
|
||
| while self.reactor.seconds() < end_time_s: | ||
| self.reactor.advance(by.as_secs()) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this fixes the actual problems, not just the symptoms.
The root cause is that
_retry_intervalcan grow pastDuration(timedelta)'s max value and raise anOverflowError. When this occurs, there is a tight-loop where theOverflowErrorpropagates up fromDuration()→clock.call_later()→timeout_deferred()→await timeout_deferred(...), where it is caught by the bareexcept Exception: passat line 254-255. The loop immediately continues to the next iteration with no waiting at all.Even the initially proposed change here fixed the problem of the
_retry_interval/timeout growing too large. Additionally, this PR has since evolved to only increase the timeout when we actually encounter adefer.TimeoutErrorwhich is another fix that would solve this.It's possible that this code could tight-loop again if we encounter some other exception over and over but I guess that's expected and our "critical" section is already as small as possible around
timeout_deferred(...). We could have some tight-loop detection but feels overkill as at some point, we have to trusttimeout_deferred(...)to do the right thing. Overall, the situation is better here as this PR adds some logging for the general exception case instead of swallowing the error and moving on so you can actually notice any tight-loop root cause now.Random dev notes
Reproduce
Duration(timedelta)raisingOverflowError