Skip to content

Commit 7c497ec

Browse files
authored
Merge pull request #159 from bjester/deferred-fk-checks
Defer foreign key checks for bulk processing during deserialization
2 parents c70d26c + ec912bc commit 7c497ec

12 files changed

Lines changed: 735 additions & 139 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ List of the most important changes for each release.
55
## 0.6.11
66
- Added deferred processing of foreign keys to allow bulk processing and to improve performance.
77
- Eliminated extraneous SQL queries for the transfer session when querying for buffers.
8+
- Added database index to Store's partition field to improve querying performance.
89

910
## 0.6.10
1011
- Fixes Django migration issue introduced in 0.6.7 allowing nullable fields with PostgreSQL backends

morango/models/core.py

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import json
55
import logging
66
import uuid
7+
from collections import defaultdict
8+
from collections import namedtuple
79
from functools import reduce
810

911
from django.core import exceptions
@@ -450,15 +452,16 @@ class Meta:
450452
models.Index(fields=['partition'], name='idx_morango_store_partition'),
451453
]
452454

453-
def _deserialize_store_model(self, fk_cache): # noqa: C901
455+
def _deserialize_store_model(self, fk_cache, defer_fks=False): # noqa: C901
454456
"""
455457
When deserializing a store model, we look at the deleted flags to know if we should delete the app model.
456458
Upon loading the app model in memory we validate the app models fields, if any errors occurs we follow
457459
foreign key relationships to see if the related model has been deleted to propagate that deletion to the target app model.
458460
We return:
459-
None => if the model was deleted successfully
460-
model => if the model validates successfully
461+
None => if the model was deleted successfully
462+
model => if the model validates successfully
461463
"""
464+
deferred_fks = {}
462465
klass_model = syncable_models.get_model(self.profile, self.model_name)
463466
# if store model marked as deleted, attempt to delete in app layer
464467
if self.deleted:
@@ -470,7 +473,7 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
470473
pass
471474
else:
472475
klass_model.objects.filter(id=self.id).delete()
473-
return None
476+
return None, deferred_fks
474477
else:
475478
# load model into memory
476479
app_model = klass_model.deserialize(json.loads(self.serialized))
@@ -479,10 +482,12 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
479482
app_model._morango_dirty_bit = False
480483

481484
try:
482-
483485
# validate and return the model
484-
app_model.cached_clean_fields(fk_cache)
485-
return app_model
486+
if defer_fks:
487+
deferred_fks = app_model.deferred_clean_fields()
488+
else:
489+
app_model.cached_clean_fields(fk_cache)
490+
return app_model, deferred_fks
486491

487492
except (exceptions.ValidationError, exceptions.ObjectDoesNotExist) as e:
488493

@@ -492,23 +497,24 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
492497
)
493498
)
494499

495-
# check FKs in store to see if any of those models were deleted or hard_deleted to propagate to this model
496-
fk_ids = [
497-
getattr(app_model, field.attname)
498-
for field in app_model._meta.fields
499-
if isinstance(field, ForeignKey)
500-
]
501-
for fk_id in fk_ids:
502-
try:
503-
st_model = Store.objects.get(id=fk_id)
504-
if st_model.deleted:
505-
# if hard deleted, propagate to store model
506-
if st_model.hard_deleted:
507-
app_model._update_hard_deleted_models()
508-
app_model._update_deleted_models()
509-
return None
510-
except Store.DoesNotExist:
511-
pass
500+
if not defer_fks and isinstance(e, exceptions.ObjectDoesNotExist):
501+
# check FKs in store to see if any of those models were deleted or hard_deleted to propagate to this model
502+
fk_ids = [
503+
getattr(app_model, field.attname)
504+
for field in app_model._meta.fields
505+
if isinstance(field, ForeignKey)
506+
]
507+
for fk_id in fk_ids:
508+
try:
509+
st_model = Store.objects.get(id=fk_id)
510+
if st_model.deleted:
511+
# if hard deleted, propagate to store model
512+
if st_model.hard_deleted:
513+
app_model._update_hard_deleted_models()
514+
app_model._update_deleted_models()
515+
return None, {}
516+
except Store.DoesNotExist:
517+
pass
512518

513519
# if we got here, it means the validation error wasn't handled by propagating deletion, so re-raise it
514520
raise e
@@ -771,6 +777,9 @@ class RecordMaxCounterBuffer(AbstractCounter):
771777
model_uuid = UUIDField(db_index=True)
772778

773779

780+
ForeignKeyReference = namedtuple("ForeignKeyReference", ["from_field", "from_pk", "to_pk"])
781+
782+
774783
class SyncableModel(UUIDModelMixin):
775784
"""
776785
``SyncableModel`` is the base model class for syncing. Other models inherit from this class if they want to make
@@ -839,13 +848,21 @@ def delete(
839848
return collector.delete()
840849

841850
def cached_clean_fields(self, fk_lookup_cache):
851+
"""
852+
Immediately validates all fields, but uses a cache for foreign key (FK) lookups to reduce
853+
repeated queries for many records with the same FK
854+
855+
:param fk_lookup_cache: A dictionary to use as a cache to prevent querying the database if a
856+
FK exists in the cache, having already been validated
857+
"""
842858
excluded_fields = []
843859
fk_fields = [
844860
field for field in self._meta.fields if isinstance(field, models.ForeignKey)
845861
]
862+
846863
for f in fk_fields:
847864
raw_value = getattr(self, f.attname)
848-
key = "morango_{id}_{db_table}_foreignkey".format(
865+
key = "{id}_{db_table}".format(
849866
db_table=f.related_model._meta.db_table, id=raw_value
850867
)
851868
try:
@@ -859,7 +876,44 @@ def cached_clean_fields(self, fk_lookup_cache):
859876
else:
860877
fk_lookup_cache[key] = 1
861878
excluded_fields.append(f.name)
879+
880+
self.clean_fields(exclude=excluded_fields)
881+
882+
# after cleaning, we can confidently set ourselves in the fk_lookup_cache
883+
self_key = "{id}_{db_table}".format(
884+
db_table=self._meta.db_table,
885+
id=self.id,
886+
)
887+
fk_lookup_cache[self_key] = 1
888+
889+
def deferred_clean_fields(self):
890+
"""
891+
Calls `.clean_fields()` but excludes all foreign key fields and instead returns them as a
892+
dictionary for deferred batch processing
893+
894+
:return: A dictionary containing lists of `ForeignKeyReference`s keyed by the name of the
895+
model being referenced by the FK
896+
"""
897+
excluded_fields = []
898+
deferred_fks = defaultdict(list)
899+
for field in self._meta.fields:
900+
if not isinstance(field, models.ForeignKey):
901+
continue
902+
# by not excluding the field if it's null, the default validation logic will apply
903+
# and should raise a ValidationError if the FK field is not nullable
904+
if getattr(self, field.attname) is None:
905+
continue
906+
excluded_fields.append(field.name)
907+
deferred_fks[field.related_model._meta.verbose_name].append(
908+
ForeignKeyReference(
909+
from_field=field.attname,
910+
from_pk=self.pk,
911+
to_pk=getattr(self, field.attname)
912+
)
913+
)
914+
862915
self.clean_fields(exclude=excluded_fields)
916+
return deferred_fks
863917

864918
def serialize(self):
865919
"""All concrete fields of the ``SyncableModel`` subclass, except for those specifically blacklisted, are returned in a dict."""

morango/sync/backends/base.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@
55

66

77
class BaseSQLWrapper(object):
8+
create_temporary_table_template = "CREATE TEMP TABLE {name} ({fields})"
9+
10+
def __init__(self, connection):
11+
self.connection = connection
12+
13+
def _create_placeholder_list(self, fields, db_values):
14+
# number of rows to update
15+
num_of_rows = len(db_values) // len(fields)
16+
# create '%s' placeholders for a single row
17+
placeholder_tuple = tuple(["%s" for _ in range(len(fields))])
18+
# create list of the '%s' tuple placeholders based on number of rows to update
19+
return [str(placeholder_tuple) for _ in range(num_of_rows)]
20+
21+
def _bulk_full_record_upsert(self, cursor, table_name, fields, db_values):
22+
raise NotImplementedError("Subclass must implement this method.")
23+
24+
def _bulk_insert(self, cursor, table_name, fields, db_values):
25+
placeholder_str = ", ".join(
26+
self._create_placeholder_list(fields, db_values)
27+
).replace("'", "")
28+
fields_str = str(tuple(str(f.attname) for f in fields)).replace("'", "")
29+
insert = """
30+
INSERT INTO {table_name} {fields}
31+
VALUES {placeholder_str}
32+
""".format(
33+
table_name=table_name, fields=fields_str, placeholder_str=placeholder_str
34+
)
35+
cursor.execute(insert, db_values)
36+
37+
def _bulk_update(self, cursor, table_name, fields, db_values):
38+
raise NotImplementedError("Subclass must implement this method.")
39+
840
def _dequeuing_delete_rmcb_records(self, cursor, transfersession_id):
941
# delete all RMCBs which are a reverse FF (store version newer than buffer version)
1042
delete_rmcb_records = """DELETE FROM {rmcb}
@@ -132,3 +164,15 @@ def _dequeuing_delete_remaining_buffer(self, cursor, transfersession_id):
132164
buffer=Buffer._meta.db_table, transfer_session_id=transfersession_id
133165
)
134166
cursor.execute(delete_remaining_buffer)
167+
168+
def _create_temporary_table(self, cursor, name, field_sqls, fields_params):
169+
"""
170+
:param cursor: The database connection cursor
171+
:param name: The str name of the temp table
172+
:param field_sqls: A list of SQL strings representing the fields
173+
:param fields_params: A list of SQL parameters if necessary for the fields SQL
174+
"""
175+
sql = self.create_temporary_table_template.format(
176+
name=name, fields=", ".join(field_sqls)
177+
)
178+
cursor.execute(sql, fields_params)

morango/sync/backends/postgres.py

Lines changed: 73 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
from django.db import connection
2-
31
from .base import BaseSQLWrapper
2+
from .utils import get_pk_field
43
from morango.models.core import Buffer
54
from morango.models.core import RecordMaxCounter
65
from morango.models.core import RecordMaxCounterBuffer
@@ -9,58 +8,98 @@
98

109
class SQLWrapper(BaseSQLWrapper):
1110
backend = "postgresql"
11+
create_temporary_table_template = (
12+
"CREATE TEMP TABLE {name} ({fields}) ON COMMIT DROP"
13+
)
1214

13-
def _bulk_insert_into_app_models(
14-
self, cursor, app_model, fields, db_values, placeholder_list
15-
):
15+
def _prepare_with_values(self, name, fields, db_values):
16+
placeholder_list = self._create_placeholder_list(fields, db_values)
1617
# convert this list to a string to be passed into raw sql query
1718
placeholder_str = ", ".join(placeholder_list).replace("'", "")
18-
# cast the values in the SET statement to their appropiate postgres db types
19-
set_casted_values = ", ".join(
19+
return """
20+
WITH {name} {fields} as
21+
(
22+
VALUES {placeholder_str}
23+
)
24+
""".format(
25+
name=name,
26+
fields=str(tuple(str(f.column) for f in fields)).replace("'", ""),
27+
placeholder_str=placeholder_str,
28+
)
29+
30+
def _prepare_casted_fields(self, fields):
31+
return ", ".join(
2032
map(
21-
lambda f: "{f} = nv.{f}::{type}".format(
22-
f=f.attname, type=f.rel_db_type(connection)
33+
lambda f: "{f}::{type}".format(
34+
f=f.column, type=f.rel_db_type(self.connection)
2335
),
2436
fields,
2537
)
2638
)
27-
# cast the values in the SELECT statement to their appropiate posgtres db types
28-
select_casted_values = ", ".join(
39+
40+
def _prepare_set_casted_values(self, fields, source_table):
41+
return ", ".join(
2942
map(
30-
lambda f: "{f}::{type}".format(
31-
f=f.attname, type=f.rel_db_type(connection)
43+
lambda f: "{f} = {src}.{f}::{type}".format(
44+
f=f.attname,
45+
type=f.rel_db_type(self.connection),
46+
src=source_table,
3247
),
3348
fields,
3449
)
3550
)
36-
# cast the pk to the correct field type for this model
37-
pk = [f for f in fields if f.primary_key][0]
38-
fields = str(tuple(str(f.attname) for f in fields)).replace("'", "")
3951

40-
insert = """
41-
WITH new_values {fields} as
42-
(
43-
VALUES {placeholder_str}
44-
),
52+
def _bulk_full_record_upsert(self, cursor, table_name, fields, db_values):
53+
pk = get_pk_field(fields)
54+
55+
cte_name = "new_values"
56+
upsert = """
57+
{cte},
4558
updated as
4659
(
47-
UPDATE {app_model} model
60+
UPDATE {table_name} model
4861
SET {set_values}
49-
FROM new_values nv
50-
WHERE model.id = nv.id::{id_type}
51-
returning model.*
62+
FROM {cte_name} cte
63+
WHERE model.id = cte.{pk_field}::{pk_type}
64+
RETURNING model.{pk_field}
5265
)
53-
INSERT INTO {app_model} {fields}
66+
INSERT INTO {table_name} {fields}
5467
SELECT {select_fields}
55-
FROM new_values ut
56-
WHERE ut.id::{id_type} not in (SELECT id FROM updated)
68+
FROM {cte_name} cte
69+
WHERE cte.{pk_field}::{pk_type} NOT IN (SELECT {pk_field} FROM updated)
5770
""".format(
58-
app_model=app_model,
59-
fields=fields,
60-
placeholder_str=placeholder_str,
61-
set_values=set_casted_values,
62-
select_fields=select_casted_values,
63-
id_type=pk.rel_db_type(connection),
71+
cte=self._prepare_with_values(cte_name, fields, db_values),
72+
cte_name=cte_name,
73+
table_name=table_name,
74+
fields=str(tuple(str(f.column) for f in fields)).replace("'", ""),
75+
set_values=self._prepare_set_casted_values(fields, "cte"),
76+
select_fields=self._prepare_casted_fields(fields),
77+
pk_field=pk.column,
78+
pk_type=pk.rel_db_type(self.connection),
79+
)
80+
# use DB-APIs parameter substitution (2nd parameter expects a sequence)
81+
cursor.execute(upsert, db_values)
82+
83+
def _bulk_update(self, cursor, table_name, fields, db_values):
84+
pk = get_pk_field(fields)
85+
86+
insert = """
87+
{cte}
88+
UPDATE {table_name} model
89+
SET {set_values}
90+
FROM {cte_name} cte
91+
WHERE model.{pk_field} = cte.{pk_field}::{pk_type}
92+
"""
93+
94+
cte_name = "new_values"
95+
insert = insert.format(
96+
cte=self._prepare_with_values(cte_name, fields, db_values),
97+
cte_name=cte_name,
98+
table_name=table_name,
99+
fields=str(tuple(str(f.column) for f in fields)).replace("'", ""),
100+
set_values=self._prepare_set_casted_values(fields, "cte"),
101+
pk_field=pk.column,
102+
pk_type=pk.rel_db_type(self.connection),
64103
)
65104
# use DB-APIs parameter substitution (2nd parameter expects a sequence)
66105
cursor.execute(insert, db_values)

0 commit comments

Comments
 (0)