Skip to content

Commit 5e295cf

Browse files
🐛 [#5058] Fix race conditions leading to database errors
**Removed the submission value variable recoupling** Before this patch, we ran some reconciliation when a form is edited to ensure that existing SubmissionValueVariable instances don't have 'null' form_variable foreign keys because the FormVariable set of a form was updated after editing (a) step(s) - the idea here was to be eventually consistent. This turns out not to be necessary (if we can trust our test suite) because the load_submission_value_variables_state method on the Submission operates on the variable keys rather than the FK relation and is able to properly resolve everything. This is also the interface that developers should use when accessing the submission values and it appears to be done properly in the registration backends, otherwise tests would likely fail. This re-coupling was extended in #4900, after it was noticed in #4824 that the re-coupling didn't happen for other forms that use the same re-usable form definition. At the time, we didn't understand how this seemingly didn't cause issues or at least didn't result in issues being reported to us, but we can now conclude that it just wasn't a problem in the first place because the proper interfaces/service layer are/were being used and everything is done/reconciled in-memory when comparing/populating submission variables with form variables. A further cleanup step will then also be to remove this FK field from the submission value variable model, as it is not necessary. **Run form variable sync in same transaction** Offloading this to celery results in more, separate tasks each competing for database locks and can lead to integrity errors. Changing the business logic of form variable creation/update to make sure a FormDefinition write results in a sync action gives a single trigger for these kind of changes, and it's responsible for updating all the forms that are affected by it. Since multiple FormDefinition/FormStep writes happen in parallel because of parallel client requests, we can only update or create variables and can't delete variables that are not present in our current form definition, as they may belong to another request. This shouldn't immediately cause problems, as old, unused variables don't have an 'execution path'. We piggy back on the variables bulk update endpoint/transaction to clean these up, as that call is made after all the form step persistence succeeded so we know that everything is resolved by then. The left-over variables problem only exists when updating a form step to use a different form definition. It's not relevant when a form definition is updated through the admin or a form step is added to a form. **Add test for expected new behaviour of bulk variable update endpoint** The bulk update endpoint may no longer manage the variables for form steps, as that's taken care of by the form step endpoint now. But, user defined variables must still be managed. **Use the form variables bulk update for consistency** Instead of triggering celery tasks, perform the variable state synchronization for a form in the bulk update endpoint. This endpoint now still validates the state of all component/user defined variables, but no longer drops all the variables and re-creates them, instead it only touches user defined variables and leaves the form step variables alone. Since this is called after the steps have been sorted out, a complete view of left-over variables from earlier form definitions is available and those can be cleaned up safely now. Backport-of: #5064
1 parent eb94ab1 commit 5e295cf

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)