Skip to content

Commit ec91252

Browse files
authored
Improve migration performance (#169)
* perform delete IO async * create only newly added sizes * delete only obsolete file sizes * create sizes from an in-memory copy of the file
1 parent f361b9c commit ec91252

File tree

8 files changed

+640
-107
lines changed

8 files changed

+640
-107
lines changed

pictures/migrations.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.db.migrations import AlterField
55
from django.db.models import Q
66

7-
from pictures.models import PictureField, PictureFieldFile
7+
from pictures.models import PictureField
88

99
__all__ = ["AlterPictureField"]
1010

@@ -47,19 +47,15 @@ def alter_picture_field(
4747
self.update_pictures(from_field, to_model)
4848

4949
def update_pictures(self, from_field: PictureField, to_model: Type[models.Model]):
50+
"""Remove obsolete pictures and create new ones."""
5051
for obj in to_model._default_manager.exclude(
5152
Q(**{self.name: ""}) | Q(**{self.name: None})
5253
).iterator():
53-
field_file = getattr(obj, self.name)
54-
field_file.update_all(
55-
from_aspect_ratios=PictureFieldFile.get_picture_files(
56-
file_name=field_file.name,
57-
img_width=field_file.width,
58-
img_height=field_file.height,
59-
storage=field_file.storage,
60-
field=from_field,
61-
)
54+
new_field_file = getattr(obj, self.name)
55+
old_field_file = from_field.attr_class(
56+
instance=obj, field=from_field, name=new_field_file.name
6257
)
58+
new_field_file.update_all(old_field_file)
6359

6460
def from_picture_field(self, from_model: Type[models.Model]):
6561
for obj in from_model._default_manager.all().iterator():

pictures/models.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ class SimplePicture:
3636
def __post_init__(self):
3737
self.aspect_ratio = Fraction(self.aspect_ratio) if self.aspect_ratio else None
3838

39+
def __hash__(self):
40+
return hash(self.name)
41+
42+
def __eq__(self, other):
43+
if not isinstance(other, type(self)):
44+
return NotImplemented
45+
return self.deconstruct() == other.deconstruct()
46+
3947
@property
4048
def url(self) -> str:
4149
if conf.get_settings().USE_PLACEHOLDERS:
@@ -93,30 +101,64 @@ def save(self, image):
93101
def delete(self):
94102
self.storage.delete(self.name)
95103

104+
def deconstruct(self):
105+
return (
106+
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
107+
(
108+
self.parent_name,
109+
self.file_type,
110+
str(self.aspect_ratio) if self.aspect_ratio else None,
111+
self.storage.deconstruct(),
112+
self.width,
113+
),
114+
{},
115+
)
116+
96117

97118
class PictureFieldFile(ImageFieldFile):
119+
120+
def __xor__(self, other) -> tuple[set[SimplePicture], set[SimplePicture]]:
121+
"""Return the new and obsolete :class:`SimpleFile` instances."""
122+
if not isinstance(other, PictureFieldFile):
123+
return NotImplemented
124+
new = self.get_picture_files_list() - other.get_picture_files_list()
125+
obsolete = other.get_picture_files_list() - self.get_picture_files_list()
126+
127+
return new, obsolete
128+
98129
def save(self, name, content, save=True):
99130
super().save(name, content, save)
100131
self.save_all()
101132

102133
def save_all(self):
103-
if self:
104-
import_string(conf.get_settings().PROCESSOR)(self)
134+
self.update_all()
105135

106136
def delete(self, save=True):
107137
self.delete_all()
108138
super().delete(save=save)
109139

110-
def delete_all(self, aspect_ratios=None):
111-
aspect_ratios = aspect_ratios or self.aspect_ratios
112-
for sources in aspect_ratios.values():
113-
for srcset in sources.values():
114-
for picture in srcset.values():
115-
picture.delete()
140+
def delete_all(self):
141+
if self:
142+
import_string(conf.get_settings().PROCESSOR)(
143+
self.storage.deconstruct(),
144+
self.name,
145+
[],
146+
[i.deconstruct() for i in self.get_picture_files_list()],
147+
)
116148

117-
def update_all(self, from_aspect_ratios):
118-
self.delete_all(from_aspect_ratios)
119-
self.save_all()
149+
def update_all(self, other: PictureFieldFile | None = None):
150+
if self:
151+
if not other:
152+
new = self.get_picture_files_list()
153+
old = []
154+
else:
155+
new, old = self ^ other
156+
import_string(conf.get_settings().PROCESSOR)(
157+
self.storage.deconstruct(),
158+
self.name,
159+
[i.deconstruct() for i in new],
160+
[i.deconstruct() for i in old],
161+
)
120162

121163
@property
122164
def width(self):
@@ -137,7 +179,7 @@ def height(self):
137179
return self._get_image_dimensions()[1]
138180

139181
@property
140-
def aspect_ratios(self):
182+
def aspect_ratios(self) -> {Fraction | None: {str: {int: SimplePicture}}}:
141183
self._require_file()
142184
return self.get_picture_files(
143185
file_name=self.name,
@@ -155,7 +197,7 @@ def get_picture_files(
155197
img_height: int,
156198
storage: Storage,
157199
field: PictureField,
158-
):
200+
) -> {Fraction | None: {str: {int: SimplePicture}}}:
159201
return {
160202
ratio: {
161203
file_type: {
@@ -172,6 +214,14 @@ def get_picture_files(
172214
for ratio in field.aspect_ratios
173215
}
174216

217+
def get_picture_files_list(self) -> set[SimplePicture]:
218+
return {
219+
picture
220+
for sources in self.aspect_ratios.values()
221+
for srcset in sources.values()
222+
for picture in srcset.values()
223+
}
224+
175225

176226
class PictureField(ImageField):
177227
attr_class = PictureFieldFile
@@ -180,7 +230,7 @@ def __init__(
180230
self,
181231
verbose_name=None,
182232
name=None,
183-
aspect_ratios: [str] = None,
233+
aspect_ratios: [str | Fraction | None] = None,
184234
container_width: int = None,
185235
file_types: [str] = None,
186236
pixel_densities: [int] = None,

pictures/tasks.py

Lines changed: 74 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,46 @@
11
from __future__ import annotations
22

3-
import importlib
3+
from typing import Protocol
44

5-
from django.apps import apps
6-
from django.core.files.storage import Storage
75
from django.db import transaction
86
from PIL import Image
97

10-
from pictures import conf
11-
from pictures.models import PictureFieldFile
8+
from pictures import conf, utils
129

1310

14-
def _process_picture(field_file: PictureFieldFile) -> None:
15-
# field_file.file may already be closed and can't be reopened.
16-
# Therefore, we always open it from storage.
17-
with field_file.storage.open(field_file.name) as fs:
18-
with Image.open(fs) as img:
19-
for ratio, sources in field_file.aspect_ratios.items():
20-
for file_type, srcset in sources.items():
21-
for width, picture in srcset.items():
22-
picture.save(img)
11+
class PictureProcessor(Protocol):
2312

13+
def __call__(
14+
self,
15+
storage: tuple[str, list, dict],
16+
file_name: str,
17+
new: list[tuple[str, list, dict]] | None = None,
18+
old: list[tuple[str, list, dict]] | None = None,
19+
) -> None: ...
2420

25-
process_picture = _process_picture
2621

22+
def _process_picture(
23+
storage: tuple[str, list, dict],
24+
file_name: str,
25+
new: list[tuple[str, list, dict]] | None = None,
26+
old: list[tuple[str, list, dict]] | None = None,
27+
) -> None:
28+
new = new or []
29+
old = old or []
30+
storage = utils.reconstruct(*storage)
31+
if new:
32+
with storage.open(file_name) as fs:
33+
with Image.open(fs) as img:
34+
for picture in new:
35+
picture = utils.reconstruct(*picture)
36+
picture.save(img)
2737

28-
def construct_storage(
29-
storage_cls: str, storage_args: tuple, storage_kwargs: dict
30-
) -> Storage:
31-
storage_module, storage_class = storage_cls.rsplit(".", 1)
32-
storage_cls = getattr(importlib.import_module(storage_module), storage_class)
33-
return storage_cls(*storage_args, **storage_kwargs)
34-
38+
for picture in old:
39+
picture = utils.reconstruct(*picture)
40+
picture.delete()
3541

36-
def process_picture_async(
37-
app_name: str, model_name: str, field_name: str, file_name: str, storage_construct
38-
) -> None:
39-
model = apps.get_model(f"{app_name}.{model_name}")
40-
field = model._meta.get_field(field_name)
41-
storage = construct_storage(*storage_construct)
4242

43-
with storage.open(file_name) as file:
44-
with Image.open(file) as img:
45-
for ratio, sources in PictureFieldFile.get_picture_files(
46-
file_name=file_name,
47-
img_width=img.width,
48-
img_height=img.height,
49-
storage=storage,
50-
field=field,
51-
).items():
52-
for file_type, srcset in sources.items():
53-
for width, picture in srcset.items():
54-
picture.save(img)
43+
process_picture: PictureProcessor = _process_picture
5544

5645

5746
try:
@@ -62,21 +51,25 @@ def process_picture_async(
6251

6352
@dramatiq.actor(queue_name=conf.get_settings().QUEUE_NAME)
6453
def process_picture_with_dramatiq(
65-
app_name, model_name, field_name, file_name, storage_construct
54+
storage: tuple[str, list, dict],
55+
file_name: str,
56+
new: list[tuple[str, list, dict]] | None = None,
57+
old: list[tuple[str, list, dict]] | None = None,
6658
) -> None:
67-
process_picture_async(
68-
app_name, model_name, field_name, file_name, storage_construct
69-
)
59+
_process_picture(storage, file_name, new, old)
7060

71-
def process_picture(field_file: PictureFieldFile) -> None: # noqa: F811
72-
opts = field_file.instance._meta
61+
def process_picture( # noqa: F811
62+
storage: tuple[str, list, dict],
63+
file_name: str,
64+
new: list[tuple[str, list, dict]] | None = None,
65+
old: list[tuple[str, list, dict]] | None = None,
66+
) -> None:
7367
transaction.on_commit(
7468
lambda: process_picture_with_dramatiq.send(
75-
app_name=opts.app_label,
76-
model_name=opts.model_name,
77-
field_name=field_file.field.name,
78-
file_name=field_file.name,
79-
storage_construct=field_file.storage.deconstruct(),
69+
storage=storage,
70+
file_name=file_name,
71+
new=new,
72+
old=old,
8073
)
8174
)
8275

@@ -92,22 +85,26 @@ def process_picture(field_file: PictureFieldFile) -> None: # noqa: F811
9285
retry_backoff=True,
9386
)
9487
def process_picture_with_celery(
95-
app_name, model_name, field_name, file_name, storage_construct
88+
storage: tuple[str, list, dict],
89+
file_name: str,
90+
new: list[tuple[str, list, dict]] | None = None,
91+
old: list[tuple[str, list, dict]] | None = None,
9692
) -> None:
97-
process_picture_async(
98-
app_name, model_name, field_name, file_name, storage_construct
99-
)
93+
_process_picture(storage, file_name, new, old)
10094

101-
def process_picture(field_file: PictureFieldFile) -> None: # noqa: F811
102-
opts = field_file.instance._meta
95+
def process_picture( # noqa: F811
96+
storage: tuple[str, list, dict],
97+
file_name: str,
98+
new: list[tuple[str, list, dict]] | None = None,
99+
old: list[tuple[str, list, dict]] | None = None,
100+
) -> None:
103101
transaction.on_commit(
104102
lambda: process_picture_with_celery.apply_async(
105103
kwargs=dict(
106-
app_name=opts.app_label,
107-
model_name=opts.model_name,
108-
field_name=field_file.field.name,
109-
file_name=field_file.name,
110-
storage_construct=field_file.storage.deconstruct(),
104+
storage=storage,
105+
file_name=file_name,
106+
new=new,
107+
old=old,
111108
),
112109
queue=conf.get_settings().QUEUE_NAME,
113110
)
@@ -122,20 +119,24 @@ def process_picture(field_file: PictureFieldFile) -> None: # noqa: F811
122119

123120
@job(conf.get_settings().QUEUE_NAME)
124121
def process_picture_with_django_rq(
125-
app_name, model_name, field_name, file_name, storage_construct
122+
storage: tuple[str, list, dict],
123+
file_name: str,
124+
new: list[tuple[str, list, dict]] | None = None,
125+
old: list[tuple[str, list, dict]] | None = None,
126126
) -> None:
127-
process_picture_async(
128-
app_name, model_name, field_name, file_name, storage_construct
129-
)
127+
_process_picture(storage, file_name, new, old)
130128

131-
def process_picture(field_file: PictureFieldFile) -> None: # noqa: F811
132-
opts = field_file.instance._meta
129+
def process_picture( # noqa: F811
130+
storage: tuple[str, list, dict],
131+
file_name: str,
132+
new: list[tuple[str, list, dict]] | None = None,
133+
old: list[tuple[str, list, dict]] | None = None,
134+
) -> None:
133135
transaction.on_commit(
134136
lambda: process_picture_with_django_rq.delay(
135-
app_name=opts.app_label,
136-
model_name=opts.model_name,
137-
field_name=field_file.field.name,
138-
file_name=field_file.name,
139-
storage_construct=field_file.storage.deconstruct(),
137+
storage=storage,
138+
file_name=file_name,
139+
new=new,
140+
old=old,
140141
)
141142
)

pictures/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,23 @@ def placeholder(width: int, height: int, alt):
112112
anchor="mm",
113113
)
114114
return img
115+
116+
117+
def reconstruct(path: str, args: list, kwargs: dict):
118+
"""Reconstruct a class instance from its deconstructed state."""
119+
module_name, _, name = path.rpartition(".")
120+
module = __import__(module_name, fromlist=[name])
121+
klass = getattr(module, name)
122+
_args = []
123+
_kwargs = {}
124+
for arg in args:
125+
try:
126+
_args.append(reconstruct(*arg))
127+
except (TypeError, ValueError, ImportError):
128+
_args.append(arg)
129+
for key, value in kwargs.items():
130+
try:
131+
_kwargs[key] = reconstruct(*value)
132+
except (TypeError, ValueError, ImportError):
133+
_kwargs[key] = value
134+
return klass(*_args, **_kwargs)

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
22
max-line-length=88
33
select = C,E,F,W,B,B950
4-
ignore = E203, E501, W503, E731
4+
ignore = E203, E501, W503, E704, E731

0 commit comments

Comments
 (0)