Skip to content

Commit c8679ab

Browse files
authored
Merge pull request #390 from bjester/streaming-deserialize-part-2
Streaming deserialization part 2: transformations
2 parents 4eacad2 + c6081d3 commit c8679ab

3 files changed

Lines changed: 1215 additions & 3 deletions

File tree

‎morango/sync/stream/deserialize.py‎

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,64 @@
1+
import json
2+
from collections import namedtuple
3+
from enum import Enum
14
from typing import Dict
25
from typing import Generator
36
from typing import List
47
from typing import Optional
8+
from typing import Set
9+
from typing import Tuple
510
from typing import Type
611

12+
from django.core import exceptions
13+
from django.db.models import ForeignKey
14+
from django.db.utils import IntegrityError
15+
16+
from morango.errors import MorangoDirtyParent
17+
from morango.errors import MorangoMissingParent
718
from morango.models.certificates import Filter
819
from morango.models.core import Store
920
from morango.models.core import SyncableModel
1021
from morango.registry import syncable_models
22+
from morango.sync.stream.core import Transform
1123
from morango.sync.stream.source import MorangoSource
1224
from morango.sync.stream.source import SourceTask
1325

26+
DIRTY_PARENT_MESSAGE = "Parent is dirty; could not deserialize."
27+
MISSING_PARENT_MESSAGE = "Parent does not exist in Store; could not deserialize."
28+
29+
# the store state of an FK target, used to resolve validation failures
30+
_TargetState = namedtuple("_TargetState", ["deleted", "hard_deleted", "dirty_bit"])
31+
32+
33+
def _fk_cache_key(model: Type[SyncableModel], record_id: str) -> str:
34+
"""The key `SyncableModel.cached_clean_fields` uses for a record in the `fk_cache`"""
35+
return "{id}_{db_table}".format(id=record_id, db_table=model._meta.db_table)
36+
37+
38+
class DeserializeOutcome(Enum):
39+
"""The resolved action for a `DeserializeTask`, which the sink applies"""
40+
41+
SAVE = "save"
42+
DELETE = "delete"
43+
PROPAGATE_DELETE = "propagate_delete"
44+
PROPAGATE_HARD_DELETE = "propagate_hard_delete"
45+
ERROR = "error"
46+
1447

1548
class DeserializeTask(SourceTask):
1649
"""Carrier class for providing context through the deserialization pipeline."""
1750

18-
__slots__ = ("store", "app_model", "fk_cache", "errors")
51+
__slots__ = ("store", "app_model", "fk_cache", "errors", "_propagated_deletion")
52+
53+
HARD_DELETION = "hard"
54+
SOFT_DELETION = "soft"
1955

2056
def __init__(self, store: Store, fk_cache: Dict):
2157
self.store = store
2258
self.fk_cache: Dict = fk_cache
2359
self.app_model: Optional[SyncableModel] = None
2460
self.errors: List[Exception] = []
61+
self._propagated_deletion: Optional[str] = None
2562

2663
@property
2764
def id(self) -> str:
@@ -35,9 +72,35 @@ def model(self) -> Type[SyncableModel]:
3572
def has_errors(self) -> bool:
3673
return len(self.errors) > 0
3774

75+
@property
76+
def outcome(self) -> DeserializeOutcome:
77+
"""
78+
The resolved `DeserializeOutcome` for this task, in order of precedence
79+
:raises AssertionError: if the pipeline has not resolved an outcome
80+
"""
81+
if self.has_errors:
82+
return DeserializeOutcome.ERROR
83+
if self.store.deleted:
84+
return DeserializeOutcome.DELETE
85+
if self._propagated_deletion == self.HARD_DELETION:
86+
return DeserializeOutcome.PROPAGATE_HARD_DELETE
87+
if self._propagated_deletion == self.SOFT_DELETION:
88+
return DeserializeOutcome.PROPAGATE_DELETE
89+
if self.app_model is not None:
90+
return DeserializeOutcome.SAVE
91+
raise AssertionError("DeserializeTask has no resolved outcome")
92+
93+
def self_referential_fk(self) -> Optional[str]:
94+
"""Return the attname of the self-referential FK on the task's model, or ``None``."""
95+
return syncable_models.get_self_referential_fk(self.model)
96+
3897
def set_app_model(self, app_model: Optional[SyncableModel]) -> None:
3998
self.app_model = app_model
4099

100+
def set_propagated_deletion(self, hard: bool) -> None:
101+
"""Mark this task's record as deleted because a record it references was deleted"""
102+
self._propagated_deletion = self.HARD_DELETION if hard else self.SOFT_DELETION
103+
41104
def add_error(self, error: Exception) -> None:
42105
self.errors.append(error)
43106

@@ -92,3 +155,209 @@ def stream_for_filter(
92155

93156
for store_model in qs.iterator():
94157
yield DeserializeTask(store_model, self.fk_cache)
158+
159+
160+
class AppModelDeserialize(Transform[DeserializeTask]):
161+
"""
162+
Builds the unsaved app model from each task's serialized store data. Deleted store records
163+
pass through untouched, since deleting the app model is left to the sink.
164+
"""
165+
166+
def __init__(self, sync_filter: Optional[Filter] = None):
167+
self.sync_filter = sync_filter
168+
169+
def transform(self, task: DeserializeTask) -> DeserializeTask:
170+
if task.store.deleted:
171+
return task
172+
173+
try:
174+
app_model = task.model.deserialize(
175+
json.loads(task.store.serialized), sync_filter=self.sync_filter
176+
)
177+
except (ValueError, exceptions.ValidationError) as e:
178+
# `json.JSONDecodeError` is a `ValueError`
179+
task.add_error(e)
180+
return task
181+
182+
app_model._morango_source_id = task.store.source_id
183+
app_model._morango_partition = task.store.partition
184+
app_model._morango_dirty_bit = False
185+
task.set_app_model(app_model)
186+
return task
187+
188+
189+
class AppModelValidate(Transform[DeserializeTask]):
190+
"""
191+
Validates each task's app model and resolves its outcome. The source streams FK targets ahead
192+
of the records that reference them, so a target validated earlier in the run is already in
193+
the shared `fk_cache` even though the sink may not have written it yet. A cache miss falls back
194+
to looking up the target in the app tables.
195+
196+
Must be instantiated per pipeline run, since it tracks which records failed or were deleted
197+
during the run, so that the records referencing them can be resolved accordingly.
198+
199+
Deletions spread to referencing records as in legacy deserialization. For models without a
200+
self-referential FK, a reference to any deleted record spreads its deletion. For models with
201+
one, only a validation failure of `ObjectDoesNotExist` spreads it.
202+
"""
203+
204+
def __init__(self, sync_filter: Optional[Filter] = None):
205+
self.sync_filter = sync_filter
206+
self.failed_ids: Set[str] = set()
207+
# maps the ID of a deleted record to whether the deletion is a hard deletion
208+
self.deleted_ids: Dict[str, bool] = {}
209+
210+
def transform(self, task: DeserializeTask) -> DeserializeTask:
211+
if task.has_errors:
212+
self.failed_ids.add(task.id)
213+
return task
214+
215+
if task.store.deleted:
216+
self.deleted_ids[task.id] = task.store.hard_deleted
217+
return task
218+
219+
fk_references = self._fk_references(task.app_model)
220+
221+
for field, target_id in fk_references:
222+
if target_id in self.deleted_ids:
223+
if task.self_referential_fk() is not None:
224+
# the sink has not deleted the target's app row yet, so validation can't
225+
# report the missing target; fail as legacy does once the row is gone
226+
self._fail(task, self._deleted_target_error(field, target_id))
227+
else:
228+
self._propagate_deletion(task, hard=self.deleted_ids[target_id])
229+
return task
230+
231+
for field, target_id in fk_references:
232+
if target_id in self.failed_ids:
233+
self._fail(task, self._failed_target_error(task, field, target_id))
234+
return task
235+
236+
try:
237+
task.app_model.cached_clean_fields(task.fk_cache, sync_filter=self.sync_filter)
238+
except (exceptions.ValidationError, exceptions.ObjectDoesNotExist, ValueError) as e:
239+
self._classify_failure(task, fk_references, e)
240+
return task
241+
242+
@staticmethod
243+
def _fk_references(app_model: SyncableModel) -> List[Tuple[ForeignKey, str]]:
244+
"""The FK fields on the app model that hold a value, paired with that value"""
245+
references = []
246+
for field in app_model._meta.fields:
247+
if not isinstance(field, ForeignKey):
248+
continue
249+
target_id = getattr(app_model, field.attname)
250+
if target_id is not None:
251+
references.append((field, target_id))
252+
return references
253+
254+
@staticmethod
255+
def _failed_target_error(task: DeserializeTask, field: ForeignKey, target_id: str) -> Exception:
256+
if field.attname == task.self_referential_fk():
257+
return MorangoDirtyParent(DIRTY_PARENT_MESSAGE)
258+
return exceptions.ValidationError(
259+
"{} with id {} failed to deserialize".format(
260+
field.related_model._meta.verbose_name, target_id
261+
)
262+
)
263+
264+
@staticmethod
265+
def _deleted_target_error(field: ForeignKey, target_id: str) -> Exception:
266+
return exceptions.ValidationError(
267+
"{} with id {} was deleted".format(field.related_model._meta.verbose_name, target_id)
268+
)
269+
270+
def _classify_failure(
271+
self,
272+
task: DeserializeTask,
273+
fk_references: List[Tuple[ForeignKey, str]],
274+
error: Exception,
275+
) -> None:
276+
"""
277+
Resolves a validation failure using the store records of the FK targets. Only runs on
278+
failure, so it costs at most one query per failed record and none per valid record.
279+
"""
280+
targets = self._lookup_targets(fk_references)
281+
282+
# a reference to a deleted record propagates the deletion, instead of failing
283+
if task.self_referential_fk() is None or isinstance(error, exceptions.ObjectDoesNotExist):
284+
for _, target_id in fk_references:
285+
target = targets.get(target_id)
286+
if target is not None and (target.deleted or target.hard_deleted):
287+
self._propagate_deletion(task, hard=target.hard_deleted)
288+
return
289+
290+
self._fail(task, self._failure_error(task, fk_references, targets, error))
291+
292+
@staticmethod
293+
def _lookup_targets(
294+
fk_references: List[Tuple[ForeignKey, str]],
295+
) -> Dict[str, _TargetState]:
296+
"""The store state of each FK target that has a store record, keyed by the target ID"""
297+
if not fk_references:
298+
return {}
299+
target_ids = [target_id for _, target_id in fk_references]
300+
return {
301+
target_id: _TargetState(deleted, hard_deleted, dirty_bit)
302+
for target_id, deleted, hard_deleted, dirty_bit in Store.objects.filter(
303+
id__in=target_ids
304+
).values_list("id", "deleted", "hard_deleted", "dirty_bit")
305+
}
306+
307+
def _failure_error(
308+
self,
309+
task: DeserializeTask,
310+
fk_references: List[Tuple[ForeignKey, str]],
311+
targets: Dict[str, _TargetState],
312+
error: Exception,
313+
) -> Exception:
314+
"""
315+
The error to record for a failure that did not propagate a deletion. Problems with the
316+
parent take precedence, then missing targets, otherwise the original validation error.
317+
"""
318+
self_ref_fk = task.self_referential_fk()
319+
for field, target_id in fk_references:
320+
if field.attname != self_ref_fk:
321+
continue
322+
if target_id not in targets:
323+
return MorangoMissingParent(MISSING_PARENT_MESSAGE)
324+
if targets[target_id].dirty_bit and self._parent_not_written(
325+
task, field, target_id, error
326+
):
327+
return MorangoDirtyParent(DIRTY_PARENT_MESSAGE)
328+
329+
for field, target_id in fk_references:
330+
if target_id not in targets:
331+
return IntegrityError(
332+
"{from_model}.{from_field} references non-existent {to_model} instance with "
333+
"id '{to_pk}'".format(
334+
from_model=task.model.__name__,
335+
from_field=field.attname,
336+
to_model=field.related_model._meta.verbose_name,
337+
to_pk=target_id,
338+
)
339+
)
340+
341+
return error
342+
343+
@staticmethod
344+
def _parent_not_written(
345+
task: DeserializeTask, field: ForeignKey, parent_id: str, error: Exception
346+
) -> bool:
347+
"""
348+
Whether a failure is explained by a dirty parent's app row not being written. The parent
349+
is in `fk_cache` when its FK resolved, either because it was accepted earlier in the run or
350+
because an older app row exists, and then only loading the parent object can fail on it.
351+
Any other error is the record's own.
352+
"""
353+
return _fk_cache_key(field.related_model, parent_id) not in task.fk_cache or isinstance(
354+
error, exceptions.ObjectDoesNotExist
355+
)
356+
357+
def _propagate_deletion(self, task: DeserializeTask, hard: bool) -> None:
358+
task.set_propagated_deletion(hard)
359+
self.deleted_ids[task.id] = hard
360+
361+
def _fail(self, task: DeserializeTask, error: Exception) -> None:
362+
task.add_error(error)
363+
self.failed_ids.add(task.id)

‎tests/testapp/facility_profile/models.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ class SyncableUserModelManager(SyncableModelManager, UserManager):
2424
class Facility(FacilityDataSyncableModel):
2525
morango_model_name = "facility"
2626

27+
# when True, clean_fields loads the parent object, which raises ObjectDoesNotExist if the
28+
# parent row is not in the app table; used to test non-validation error handling
29+
clean_dereferences_parent = True
30+
2731
name = models.CharField(max_length=100)
2832
now_date = models.DateTimeField(default=timezone.now)
2933
parent = models.ForeignKey(
@@ -45,8 +49,9 @@ def calculate_partition(self, *args, **kwargs):
4549
return "{id}".format(id=self.ID_PLACEHOLDER)
4650

4751
def clean_fields(self, *args, **kwargs):
48-
# reference parent here just to trigger a non-validation error to make sure we handle it
49-
_ = self.parent
52+
if self.clean_dereferences_parent:
53+
# reference parent here just to trigger a non-validation error to make sure we handle it
54+
_ = self.parent
5055
super(Facility, self).clean_fields(*args, **kwargs)
5156

5257

0 commit comments

Comments
 (0)