snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
217
zerver/transaction_tests/test_user_groups.py
Normal file
217
zerver/transaction_tests/test_user_groups.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
from django.db import OperationalError, connections, transaction
|
||||
from django.http import HttpRequest
|
||||
from typing_extensions import override
|
||||
|
||||
from zerver.actions.user_groups import add_subgroups_to_user_group, check_add_user_group
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.test_classes import ZulipTransactionTestCase
|
||||
from zerver.lib.test_helpers import HostRequestMock
|
||||
from zerver.lib.user_groups import access_user_group_for_update
|
||||
from zerver.models import NamedUserGroup, Realm, UserGroup, UserProfile
|
||||
from zerver.models.realms import get_realm
|
||||
from zerver.views.user_groups import update_subgroups_of_user_group
|
||||
|
||||
BARRIER: threading.Barrier | None = None
|
||||
|
||||
|
||||
def dev_update_subgroups(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
user_group_id: int,
|
||||
) -> str | None:
|
||||
# The test is expected to set up the barrier before accessing this endpoint.
|
||||
assert BARRIER is not None
|
||||
try:
|
||||
with (
|
||||
mock.patch("zerver.lib.user_groups.access_user_group_for_update") as m,
|
||||
):
|
||||
|
||||
def wait_after_recursive_query(*args: Any, **kwargs: Any) -> UserGroup:
|
||||
# When updating the subgroups, we access the supergroup group
|
||||
# only after finishing the recursive query.
|
||||
BARRIER.wait()
|
||||
return access_user_group_for_update(*args, **kwargs)
|
||||
|
||||
m.side_effect = wait_after_recursive_query
|
||||
|
||||
update_subgroups_of_user_group(request, user_profile, user_group_id=user_group_id)
|
||||
except OperationalError as err:
|
||||
msg = str(err)
|
||||
if "deadlock detected" in msg:
|
||||
return "Deadlock detected"
|
||||
else:
|
||||
assert "could not obtain lock" in msg
|
||||
# This error is possible when nowait is set the True, which only
|
||||
# applies to the recursive query on the subgroups. Because the
|
||||
# recursive query fails, this thread must have not waited on the
|
||||
# barrier yet.
|
||||
BARRIER.wait()
|
||||
return "Busy lock detected"
|
||||
except (
|
||||
threading.BrokenBarrierError
|
||||
): # nocoverage # This is only possible when timeout happens or there is a programming error
|
||||
raise JsonableError(
|
||||
"Broken barrier. The tester should make sure that the exact number of parties have waited on the barrier set by the previous immediate set_sync_after_first_lock call"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class UserGroupRaceConditionTestCase(ZulipTransactionTestCase):
|
||||
created_user_groups: list[NamedUserGroup] = []
|
||||
counter = 0
|
||||
CHAIN_LENGTH = 3
|
||||
|
||||
@override
|
||||
def tearDown(self) -> None:
|
||||
# Clean up the user groups created to minimize leakage
|
||||
with transaction.atomic(durable=True):
|
||||
for group in self.created_user_groups:
|
||||
# can_manage_group can be deleted as long as it's the
|
||||
# default group_creator. If we start using non-default
|
||||
# can_manage_group in this test, deleting that group
|
||||
# should be reconsidered.
|
||||
can_manage_group = group.can_manage_group
|
||||
can_add_members_group = group.can_add_members_group
|
||||
group.delete()
|
||||
can_manage_group.delete()
|
||||
can_add_members_group.delete()
|
||||
transaction.on_commit(self.created_user_groups.clear)
|
||||
|
||||
super().tearDown()
|
||||
|
||||
def create_user_group_chain(self, realm: Realm) -> list[NamedUserGroup]:
|
||||
"""Build a user groups forming a chain through group-group memberships
|
||||
returning a list where each group is the supergroup of its subsequent group.
|
||||
"""
|
||||
iago = self.example_user("iago")
|
||||
groups = [
|
||||
check_add_user_group(realm, f"chain #{self.counter + i}", [], acting_user=iago)
|
||||
for i in range(self.CHAIN_LENGTH)
|
||||
]
|
||||
self.counter += self.CHAIN_LENGTH
|
||||
self.created_user_groups.extend(groups)
|
||||
prev_group = groups[0]
|
||||
for group in groups[1:]:
|
||||
add_subgroups_to_user_group(prev_group, [group], acting_user=iago)
|
||||
prev_group = group
|
||||
return groups
|
||||
|
||||
def test_lock_subgroups_with_respect_to_supergroup(self) -> None:
|
||||
realm = get_realm("zulip")
|
||||
self.login("iago")
|
||||
iago = self.example_user("iago")
|
||||
|
||||
class RacingThread(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
subgroup_ids: list[int],
|
||||
supergroup_id: int,
|
||||
) -> None:
|
||||
threading.Thread.__init__(self)
|
||||
self.response: str | None = None
|
||||
self.subgroup_ids = subgroup_ids
|
||||
self.supergroup_id = supergroup_id
|
||||
|
||||
@override
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.response = dev_update_subgroups(
|
||||
HostRequestMock({"add": orjson.dumps(self.subgroup_ids).decode()}),
|
||||
iago,
|
||||
user_group_id=self.supergroup_id,
|
||||
)
|
||||
finally:
|
||||
# Close all thread-local database connections
|
||||
connections.close_all()
|
||||
|
||||
def assert_thread_success_count(
|
||||
t1: RacingThread,
|
||||
t2: RacingThread,
|
||||
*,
|
||||
success_count: int,
|
||||
error_message: str = "",
|
||||
) -> None:
|
||||
help_msg = """We access the test endpoint that wraps around the
|
||||
real subgroup update endpoint by synchronizing them after the acquisition of the
|
||||
first lock in the critical region. Though unlikely, this test might fail as we
|
||||
have no control over the scheduler when the barrier timeouts.
|
||||
""".strip()
|
||||
global BARRIER
|
||||
BARRIER = threading.Barrier(parties=2, timeout=3)
|
||||
t1.start()
|
||||
t2.start()
|
||||
|
||||
succeeded = 0
|
||||
for t in [t1, t2]:
|
||||
t.join()
|
||||
response = t.response
|
||||
if response is None:
|
||||
succeeded += 1
|
||||
continue
|
||||
|
||||
self.assertEqual(response, error_message)
|
||||
# Race condition resolution should only allow one thread to succeed
|
||||
self.assertEqual(
|
||||
succeeded,
|
||||
success_count,
|
||||
f"Exactly {success_count} thread(s) should succeed.\n{help_msg}",
|
||||
)
|
||||
|
||||
foo_chain = self.create_user_group_chain(realm)
|
||||
bar_chain = self.create_user_group_chain(realm)
|
||||
# These two threads are conflicting because a cycle would be formed if
|
||||
# both of them succeed. There is a deadlock in such circular dependency.
|
||||
assert_thread_success_count(
|
||||
RacingThread(
|
||||
subgroup_ids=[foo_chain[0].id],
|
||||
supergroup_id=bar_chain[-1].id,
|
||||
),
|
||||
RacingThread(
|
||||
subgroup_ids=[bar_chain[-1].id],
|
||||
supergroup_id=foo_chain[0].id,
|
||||
),
|
||||
success_count=1,
|
||||
error_message="Deadlock detected",
|
||||
)
|
||||
|
||||
foo_chain = self.create_user_group_chain(realm)
|
||||
bar_chain = self.create_user_group_chain(realm)
|
||||
# These two requests would succeed if they didn't race with each other.
|
||||
# However, both threads will attempt to grab a lock on overlapping rows
|
||||
# when they first do the recursive query for subgroups. In this case, we
|
||||
# expect that one of the threads fails due to nowait=True for the
|
||||
# .select_for_update() call.
|
||||
assert_thread_success_count(
|
||||
RacingThread(
|
||||
subgroup_ids=[foo_chain[0].id],
|
||||
supergroup_id=bar_chain[-1].id,
|
||||
),
|
||||
RacingThread(
|
||||
subgroup_ids=[foo_chain[1].id],
|
||||
supergroup_id=bar_chain[-1].id,
|
||||
),
|
||||
success_count=1,
|
||||
error_message="Busy lock detected",
|
||||
)
|
||||
|
||||
foo_chain = self.create_user_group_chain(realm)
|
||||
bar_chain = self.create_user_group_chain(realm)
|
||||
baz_chain = self.create_user_group_chain(realm)
|
||||
# Adding non-conflicting subgroups should succeed.
|
||||
assert_thread_success_count(
|
||||
RacingThread(
|
||||
subgroup_ids=[foo_chain[1].id, foo_chain[2].id, baz_chain[2].id],
|
||||
supergroup_id=baz_chain[0].id,
|
||||
),
|
||||
RacingThread(
|
||||
subgroup_ids=[bar_chain[1].id, bar_chain[2].id],
|
||||
supergroup_id=baz_chain[0].id,
|
||||
),
|
||||
success_count=2,
|
||||
)
|
||||
Reference in New Issue
Block a user