Skip to content

Commit 1958706

Browse files
Merge pull request #5067 from open-formulieren/backport/5058-to-30x
(backport) Fix race conditions leading to database errors
2 parents c99dc8e + 5e295cf commit 1958706

12 files changed

+527
-820
lines changed

src/openforms/api/serializers.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from django.utils.module_loading import import_string
24
from django.utils.translation import gettext_lazy as _
35

@@ -69,6 +71,7 @@ class ValidationErrorSerializer(ExceptionSerializer):
6971

7072
class ListWithChildSerializer(serializers.ListSerializer):
7173
child_serializer_class = None # class or dotted import path
74+
bulk_create_kwargs: dict[str, Any] | None = None
7275

7376
def __init__(self, *args, **kwargs):
7477
child_serializer_class = self.get_child_serializer_class()
@@ -94,7 +97,8 @@ def create(self, validated_data):
9497
obj = model(**data_dict)
9598
objects_to_create.append(self.process_object(obj))
9699

97-
return model._default_manager.bulk_create(objects_to_create)
100+
kwargs = self.bulk_create_kwargs or {}
101+
return model._default_manager.bulk_create(objects_to_create, **kwargs)
98102

99103

100104
class PublicFieldsSerializerMixin:

src/openforms/forms/admin/form_definition.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib import admin, messages
22
from django.contrib.admin.actions import delete_selected as _delete_selected
33
from django.db.models import Prefetch
4+
from django.http import HttpRequest
45
from django.urls import reverse
56
from django.utils.html import format_html, format_html_join
67
from django.utils.translation import gettext_lazy as _
@@ -9,7 +10,7 @@
910

1011
from ...utils.expressions import FirstNotBlank
1112
from ..forms import FormDefinitionForm
12-
from ..models import FormDefinition, FormStep
13+
from ..models import FormDefinition, FormStep, FormVariable
1314
from .mixins import FormioConfigMixin
1415

1516

@@ -100,3 +101,7 @@ def used_in_forms(self, obj) -> str:
100101
return format_html("<ul>{}</ul>", ret)
101102

102103
used_in_forms.short_description = _("used in")
104+
105+
def save_model(self, request: HttpRequest, obj: FormDefinition, form, change):
106+
super().save_model(request=request, obj=obj, form=form, change=change)
107+
FormVariable.objects.synchronize_for(obj)

src/openforms/forms/api/serializers/form_step.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from functools import partial
2-
31
from django.db import transaction
42
from django.db.models import Q
53

@@ -9,8 +7,7 @@
97
from openforms.api.serializers import PublicFieldsSerializerMixin
108
from openforms.translations.api.serializers import ModelTranslationsSerializer
119

12-
from ...models import FormDefinition, FormStep
13-
from ...tasks import on_formstep_save_event
10+
from ...models import FormDefinition, FormStep, FormVariable
1411
from ...validators import validate_no_duplicate_keys_across_steps
1512
from ..validators import FormStepIsApplicableIfFirstValidator
1613
from .button_text import ButtonTextSerializer
@@ -172,12 +169,11 @@ def save(self, **kwargs):
172169
Bandaid fix for #4824
173170
174171
Ensure that the FormVariables are in line with the state of the FormDefinitions
175-
after saving
172+
after saving.
176173
"""
177174
instance = super().save(**kwargs)
178175

179-
transaction.on_commit(
180-
partial(on_formstep_save_event, instance.form.id, countdown=60)
181-
)
176+
# call this synchronously so that it's part of the same DB transaction.
177+
FormVariable.objects.synchronize_for(instance.form_definition)
182178

183179
return instance

src/openforms/forms/api/serializers/form_variable.py

+36-9
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,48 @@
1717
from ...models import Form, FormDefinition, FormVariable
1818

1919

20+
def save_fetch_config(data):
21+
if config := data.get("service_fetch_configuration"):
22+
config.save()
23+
data["service_fetch_configuration"] = config.instance
24+
return data
25+
26+
2027
class FormVariableListSerializer(ListWithChildSerializer):
28+
bulk_create_kwargs = {
29+
"update_conflicts": True,
30+
"update_fields": (
31+
"name",
32+
"key",
33+
"source",
34+
"service_fetch_configuration",
35+
"prefill_plugin",
36+
"prefill_attribute",
37+
"prefill_identifier_role",
38+
"prefill_options",
39+
"data_type",
40+
"data_format",
41+
"is_sensitive_data",
42+
"initial_value",
43+
),
44+
"unique_fields": ("form", "key"),
45+
}
46+
2147
def get_child_serializer_class(self):
2248
return FormVariableSerializer
2349

24-
def process_object(self, variable: FormVariable):
25-
variable.check_data_type_and_initial_value()
26-
return variable
50+
def process_object(self, obj: FormVariable):
51+
obj.check_data_type_and_initial_value()
52+
return obj
2753

2854
def preprocess_validated_data(self, validated_data):
29-
def save_fetch_config(data):
30-
if config := data.get("service_fetch_configuration"):
31-
config.save()
32-
data["service_fetch_configuration"] = config.instance
33-
return data
34-
55+
# only process not-component variables, as those are managed via the form
56+
# step endpoint
57+
validated_data = [
58+
item
59+
for item in validated_data
60+
if (item["source"] != FormVariableSources.component)
61+
]
3562
return map(save_fetch_config, validated_data)
3663

3764
def validate(self, attrs):

src/openforms/forms/api/viewsets.py

+14-9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import inspect
2-
from functools import partial
32
from uuid import UUID
43

54
from django.conf import settings
@@ -28,7 +27,6 @@
2827

2928
from ..messages import add_success_message
3029
from ..models import Form, FormDefinition, FormStep, FormVersion
31-
from ..tasks import on_variables_bulk_update_event
3230
from ..utils import export_form, import_form
3331
from .datastructures import FormVariableWrapper
3432
from .documentation import get_admin_fields_markdown
@@ -472,10 +470,6 @@ def admin_message(self, request, *args, **kwargs):
472470
@transaction.atomic
473471
def variables_bulk_update(self, request, *args, **kwargs):
474472
form = self.get_object()
475-
form_variables = form.formvariable_set.all()
476-
# We expect that all the variables that should be associated with a form come in the request.
477-
# So we can delete any existing variables because they will be replaced.
478-
form_variables.delete()
479473

480474
serializer = FormVariableSerializer(
481475
data=request.data,
@@ -494,9 +488,20 @@ def variables_bulk_update(self, request, *args, **kwargs):
494488
},
495489
)
496490
serializer.is_valid(raise_exception=True)
497-
serializer.save()
498-
499-
transaction.on_commit(partial(on_variables_bulk_update_event, form.id))
491+
variables = serializer.save()
492+
493+
# clean up the stale variables:
494+
# 1. component variables that are no longer related to the form
495+
stale_component_vars = form.formvariable_set.exclude(
496+
form_definition__formstep__form=form
497+
).filter(source=FormVariableSources.component)
498+
stale_component_vars.delete()
499+
# 2. User defined variables not present in the submitted variables
500+
keys_to_keep = [variable.key for variable in variables]
501+
stale_user_defined = form.formvariable_set.filter(
502+
source=FormVariableSources.user_defined
503+
).exclude(key__in=keys_to_keep)
504+
stale_user_defined.delete()
500505

501506
return Response(serializer.data, status=status.HTTP_200_OK)
502507

src/openforms/forms/models/form_variable.py

+113-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from typing import TYPE_CHECKING
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
4+
from typing import TYPE_CHECKING, ClassVar
25

36
from django.db import models, transaction
47
from django.db.models import CheckConstraint, Q
@@ -31,17 +34,17 @@
3134
USER_DEFINED = Q(source=FormVariableSources.user_defined)
3235

3336

34-
class FormVariableManager(models.Manager):
37+
class FormVariableManager(models.Manager["FormVariable"]):
3538
use_in_migrations = True
3639

3740
@transaction.atomic
3841
def create_for_form(self, form: "Form") -> None:
3942
form_steps = form.formstep_set.select_related("form_definition")
4043

4144
for form_step in form_steps:
42-
self.create_for_formstep(form_step)
45+
self.synchronize_for(form_step.form_definition)
4346

44-
def create_for_formstep(self, form_step: "FormStep") -> list["FormVariable"]:
47+
def create_for_formstep(self, form_step: FormStep) -> list[FormVariable]:
4548
form_definition_configuration = form_step.form_definition.configuration
4649
component_keys = [
4750
component["key"]
@@ -101,6 +104,109 @@ def create_for_formstep(self, form_step: "FormStep") -> list["FormVariable"]:
101104

102105
return self.bulk_create(form_variables)
103106

107+
@transaction.atomic
108+
def synchronize_for(self, form_definition: FormDefinition):
109+
"""
110+
Synchronize the form variables for a given form definition.
111+
112+
This creates, updates and/or removes form variables related to the provided
113+
:class:`FormDefinition` instance. It needs to be called whenever the Formio
114+
configuration of a form definition is changed so that our form variables in each
115+
form making use of the form definition accurately reflect the configuration.
116+
117+
Note that we *don't* remove variables related to other form definitions, as
118+
multiple isolated transactions for different form definitions can happen at the
119+
same time.
120+
"""
121+
# Build the desired state
122+
desired_variables: list[FormVariable] = []
123+
# XXX: looping over the configuration_wrapper is not (yet) viable because it
124+
# also yields the components nested inside edit grids, which we need to ignore.
125+
# So, we stick to iter_components. Performance wise this should be okay since we
126+
# only need to do one pass.
127+
configuration = form_definition.configuration
128+
for component in iter_components(configuration=configuration, recursive=True):
129+
# we need to ignore components that don't actually hold any values - there's
130+
# no point to create variables for those.
131+
if is_layout_component(component):
132+
continue
133+
if component["type"] in ("content", "softRequiredErrors"):
134+
continue
135+
if component_in_editgrid(configuration, component):
136+
continue
137+
138+
# extract options from the component
139+
prefill_plugin = glom(component, "prefill.plugin", default="") or ""
140+
prefill_attribute = glom(component, "prefill.attribute", default="") or ""
141+
prefill_identifier_role = glom(
142+
component, "prefill.identifierRole", default=IdentifierRoles.main
143+
)
144+
145+
desired_variables.append(
146+
self.model(
147+
form=None, # will be set later when visiting all affected forms
148+
key=component["key"],
149+
form_definition=form_definition,
150+
prefill_plugin=prefill_plugin,
151+
prefill_attribute=prefill_attribute,
152+
prefill_identifier_role=prefill_identifier_role,
153+
name=component.get("label") or component["key"],
154+
is_sensitive_data=component.get("isSensitiveData", False),
155+
source=FormVariableSources.component,
156+
data_type=get_component_datatype(component),
157+
initial_value=get_component_default_value(component),
158+
)
159+
)
160+
161+
desired_keys = [variable.key for variable in desired_variables]
162+
163+
# if the Formio configuration of the form definition itself is updated and
164+
# components have been removed or their keys have changed, we know for certain
165+
# we can discard those old form variables - it doesn't matter which form they
166+
# belong to.
167+
stale_variables = self.filter(form_definition=form_definition).exclude(
168+
key__in=desired_keys
169+
)
170+
stale_variables.delete()
171+
172+
# check which form (steps) are affected and patch them up. It is irrelevant whether
173+
# the form definition is re-usable or not, though semantically at most one form step
174+
# should be found for single-use form definitions.
175+
# fmt: off
176+
affected_form_steps = (
177+
form_definition
178+
.formstep_set # pyright: ignore[reportAttributeAccessIssue]
179+
.select_related("form")
180+
)
181+
# fmt: on
182+
183+
# Finally, collect all the instances and efficiently upsert them - creating missing
184+
# variables and updating existing variables in a single query.
185+
to_upsert: list[FormVariable] = []
186+
for step in affected_form_steps:
187+
for variable in desired_variables:
188+
form_specific_variable = deepcopy(variable)
189+
form_specific_variable.form = step.form
190+
to_upsert.append(form_specific_variable)
191+
192+
self.bulk_create(
193+
to_upsert,
194+
# enables UPSERT behaviour so that existing records get updated and missing
195+
# records inserted
196+
update_conflicts=True,
197+
update_fields=(
198+
"prefill_plugin",
199+
"prefill_attribute",
200+
"prefill_identifier_role",
201+
"name",
202+
"is_sensitive_data",
203+
"source",
204+
"data_type",
205+
"initial_value",
206+
),
207+
unique_fields=("form", "key"),
208+
)
209+
104210

105211
class FormVariable(models.Model):
106212
form = models.ForeignKey(
@@ -198,7 +304,9 @@ class FormVariable(models.Model):
198304
null=True,
199305
)
200306

201-
objects = FormVariableManager()
307+
objects: ClassVar[ # pyright: ignore[reportIncompatibleVariableOverride]
308+
FormVariableManager
309+
] = FormVariableManager()
202310

203311
class Meta:
204312
verbose_name = _("Form variable")

0 commit comments

Comments
 (0)