Skip to content

Commit bea7350

Browse files
committed
Implement cache lock when saving files
Implement a locking mechanism to storage.py that prevents race conditions when saving files to the mirrors. Implement tests. Refs. TS-2404
1 parent f647eb8 commit bea7350

File tree

2 files changed

+78
-18
lines changed

2 files changed

+78
-18
lines changed

django/thunderstore/core/storage.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import IO, Any, Dict, Optional, TypedDict
22

33
from django.conf import settings
4+
from django.core.cache import cache
45
from django.utils.deconstruct import deconstructible
56
from storages.backends.s3boto3 import S3Boto3Storage # type: ignore
67

@@ -46,11 +47,20 @@ def save(
4647
Calling .save() closes the file, so use temporary copies for
4748
mirrors and call the main bucket with the actual file last.
4849
"""
49-
for storage_mirror in self.mirrors:
50-
with TemporarySpooledCopy(content) as tmp_content:
51-
storage_mirror.save(name, tmp_content, max_length)
5250

53-
return super().save(name, content, max_length)
51+
cache_key = f"cache_mirror_storage_{name}"
52+
53+
if not cache.add(cache_key, "LOCKED", timeout=5):
54+
raise Exception("Another save operation is in progress for this file.")
55+
56+
try:
57+
for storage_mirror in self.mirrors:
58+
with TemporarySpooledCopy(content) as tmp_content:
59+
storage_mirror.save(name, tmp_content, max_length)
60+
61+
return super().save(name, content, max_length)
62+
finally:
63+
cache.delete(cache_key)
5464

5565
def delete(self, name: str) -> None:
5666
"""

django/thunderstore/core/tests/test_storage.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from unittest.mock import patch
2+
13
import pytest
4+
from django.core.cache import cache
25
from django.core.files.storage import default_storage
36
from django.test import override_settings
47
from PIL import Image # type: ignore
@@ -7,19 +10,9 @@
710
from thunderstore.repository.factories import PackageVersionFactory
811
from thunderstore.repository.models.package_version import get_version_png_filepath
912

10-
11-
def test_get_storage_class_or_stub(mocker) -> None:
12-
assert get_storage_class_or_stub("non.stub.class") == "non.stub.class"
13-
mocker.patch("sys.argv", ["manage.py", "migrate"])
14-
assert (
15-
get_storage_class_or_stub("non.stub.class")
16-
== "thunderstore.utils.makemigrations.StubStorage"
17-
)
18-
19-
20-
@override_settings(
21-
DEFAULT_FILE_STORAGE="thunderstore.core.storage.MirroredS3Storage",
22-
S3_MIRRORS=(
13+
mirrored_storage_settings = {
14+
"DEFAULT_FILE_STORAGE": "thunderstore.core.storage.MirroredS3Storage",
15+
"S3_MIRRORS": (
2316
{
2417
"access_key": "thunderstore",
2518
"secret_key": "thunderstore",
@@ -34,7 +27,19 @@ def test_get_storage_class_or_stub(mocker) -> None:
3427
"object_parameters": {},
3528
},
3629
),
37-
)
30+
}
31+
32+
33+
def test_get_storage_class_or_stub(mocker) -> None:
34+
assert get_storage_class_or_stub("non.stub.class") == "non.stub.class"
35+
mocker.patch("sys.argv", ["manage.py", "migrate"])
36+
assert (
37+
get_storage_class_or_stub("non.stub.class")
38+
== "thunderstore.utils.makemigrations.StubStorage"
39+
)
40+
41+
42+
@override_settings(**mirrored_storage_settings)
3843
@pytest.mark.django_db
3944
def test_mirrored_storage(dummy_image: Image) -> None:
4045
pv = PackageVersionFactory(icon=None, name="MirrorStorageTest")
@@ -55,3 +60,48 @@ def test_mirrored_storage(dummy_image: Image) -> None:
5560
assert not default_storage.exists(icon_path)
5661
for mirror_storage in default_storage.mirrors:
5762
assert not mirror_storage.exists(icon_path)
63+
64+
65+
def setup_package_version(dummy_image):
66+
pv = PackageVersionFactory(icon=None, name="MirrorStorageTest")
67+
icon_path = get_version_png_filepath(pv, "")
68+
pv.icon = dummy_image
69+
return pv, icon_path
70+
71+
72+
@override_settings(**mirrored_storage_settings)
73+
@pytest.mark.django_db
74+
def test_mirrored_storage_save_lock_failure(dummy_image: Image) -> None:
75+
"""Test that save operation raises an exception if another save is in progress."""
76+
77+
message = "Another save operation is in progress for this file."
78+
pv, icon_path = setup_package_version(dummy_image)
79+
pv.icon = dummy_image
80+
cache.add(f"cache_mirror_storage_{icon_path}", "LOCKED", timeout=5)
81+
82+
with pytest.raises(Exception, match=message):
83+
pv.save()
84+
85+
assert cache.get(f"cache_mirror_storage_{icon_path}") == "LOCKED"
86+
87+
88+
@override_settings(**mirrored_storage_settings)
89+
@pytest.mark.django_db
90+
@patch("thunderstore.core.storage.TemporarySpooledCopy")
91+
@patch("django.core.cache.cache.add")
92+
@patch("django.core.cache.cache.delete")
93+
def test_mirrored_storage_save_cleanup_on_exception(
94+
mock_cache_delete, mock_cache_add, mock_temporary_spooled_copy, dummy_image: Image
95+
) -> None:
96+
"""Test that the lock is released even if an exception occurs during save."""
97+
98+
message = "Error"
99+
mock_temporary_spooled_copy.side_effect = Exception(message)
100+
mock_cache_add.return_value = True
101+
pv, icon_path = setup_package_version(dummy_image)
102+
103+
with pytest.raises(Exception, match=message):
104+
pv.save()
105+
106+
mock_cache_delete.assert_called_once_with(f"cache_mirror_storage_{icon_path}")
107+
assert cache.get(f"cache_mirror_storage_{icon_path}") is None

0 commit comments

Comments
 (0)