Dr Alexis Petrounias

Information Systems Engineer

Europe

Propagating field changes in Django Model instance aliases

written on in categories Django Programming

Django’s ORM creates multiple aliases for the same Model instance. In certain situations this may result in problematic behavior, as multiple operations on the same instance are interleaved, however, field access happens on different aliases of the instance, ultimately resulting in loss of updates.

Example Problem

For instance, consider the following two Model definitions:

1
2
3
4
5
6
7
8
9
class A(Model):
    pass

class B(Model):
    a = OneToOneField(A)
    value = IntegerField()
    
    def __unicode__(self):
        return str(self.value)

And the following sequence of statements:

>>> a = A.objects.create()
>>> b = B.objects.create(a = a, value = 69)
>>> b.value
69
>>> a.b.value
69
>>> b.value = 42
>>> b.save()
>>> b.value
42
>>> a.b.value
69

Predictably, the two instances of B are different objects, and updating one has no effect on the rest, as witnessed by the different values of 42 and 69.

Automatic Propagation

It is possible to automatically propagate field changes to all aliases of a Model by:

  • a) keeping track of aliases, and
  • b) propagating upon assignment.

Assuming you have a list of all aliases of a Model instance (see below), then field changes from a reference alias to all the remainder target aliases is achieved in the following straightforward way:

def propagate_field_update(reference, field_name,
    targets):
    value = getattr(reference, field_name)
    for target in targets:
        setattr(target, field_name, value)

Alternate Method

Another option is to update all fields together (for instance, after a save signal, as done below); this is achieved in the following way:

def propagate_field_updates(reference, targets):
    mapping = [(f.attname,
        getattr(reference, f.attname)) for f in \
            reference._meta.fields]
    for target in targets:
        for (f_attname, v) in mapping:
            setattr(target, f_attname, v)

Note, however, that updates to fields which are not handled through Django’s Model fields will not be captured using the above technique.

Mini Framework

The following mini-framework uses post initialization and post save signal listeners to maintain weak references of aliases, as well as propagates field changes to all aliases using post save and post delete signals:

from django.db.models.signals import post_init, \
    post_save, post_delete
from collections import defaultdict
from weakref import WeakValueDictionary

MODEL_INSTANCES_ALIASES = defaultdict(
    WeakValueDictionary)

def record_alias(sender, instance, **kwargs):
    """
    Keeps a weak reference to all aliases of a Model
    instance, using the class and primary key as a key
    to a default dictionary of weak value dictionary
    entries, which in turn use the id of aliases
    mapped to weak references to the aliases
    themselves, in the following manner:

    { (Class, PK) : { id : instance,
        id : instance, ... }, ... }

    Recording will happen post initialization, but
    also post save in the event that a Model instance
    is created directly from a class (and not the
    create Manager method), and hence does not yet
    feature a primary key.
    """
    if instance.pk is not None:
        aliases = MODEL_INSTANCES_ALIASES[(sender,
            instance.pk)]
        if not aliases.has_key(id(instance)):
            aliases[id(instance)] = instance

post_init.connect(record_alias)
post_save.connect(record_alias)

def propagate_updates_to_aliases(sender, instance,
    **kwargs):
    """
    Propagates all field values of a given Model
    instance to all its aliases post save and delete.
    Can also be invoked explicitly, or used in a
    decorator for methods which update fields.
    """

    mapping = [(f.attname,
        getattr(instance, f.attname)) for f in \
            instance._meta.fields]

    # Propagate field values to all aliases.
    for target in MODEL_INSTANCES_ALIASES[
        (instance.__class__, instance.pk)
    ].itervalues():
        # Ignore the given instance.
        if not id(target) == id(instance):
            for (f_attname, v) in mapping:
                setattr(target, f_attname, v)

post_save.connect(propagate_updates_to_aliases)
post_delete.connect(propagate_updates_to_aliases)

Using the above technique, our example changed as follows:

>>> a = A.objects.create()
>>> b = B.objects.create(a = a, value = 69)
>>> b.value
69
>>> a.b.value
69
>>> b.value = 42
>>> b.save()
>>> b.value
42
>>> a.b.value
42

Which demonstrates that the two aliases have consistent field values.

I’m currently experimenting with a mini-framework for propagating field updates as they happen. I am also planning on determining the kind of performance and memory impact this has on realistic Django production code; more updates in the future.