Opened 3 weeks ago

Closed 6 days ago

Last modified 4 days ago

#37436 closed Bug (fixed)

FETCH_PEERS can overwrite deferred-field changes and raise KeyError for deleted peers

Reported by: Patel Namraa Owned by: Patel Namraa
Component: Database layer (models, ORM) Version: 6.1
Severity: Release blocker Keywords:
Cc: Patel Namraa, Adam Johnson Triage Stage: Ready for checkin
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Jacob Walls)

When using FETCH_PEERS with deferred fields, DeferredAttribute.fetch_many() does not correctly handle peer instances whose state has changed since the queryset was evaluated.

There are two related problems.

  1. Locally assigned values can be overwritten

Consider two objects loaded with a deferred field using FETCH_PEERS:

class Author(models.Model):
name = models.CharField(max_length=100)

authors = list(
Author.objects.defer("name").fetch_mode(FETCH_PEERS)
)

authors[0].name = "locally changed"

# Fetch the deferred field for another peer.

_ = authors[1].name

Fetching authors[1].name causes fetch_many() to populate the field on all peers. This also replaces authors[0].name with the value currently stored in the database.

The locally assigned value is therefore silently lost.

This does not happen with FETCH_ONE, where the locally assigned value is preserved.

  1. A deleted peer can cause KeyError

A second problem occurs when one of the peer rows is deleted after the queryset has been evaluated.

fetch_many() uses in_bulk() to retrieve the deferred values and then indexes the returned dictionary for every peer:

value_by_pk = (
self.field.model._base_manager.using(db)
.values_list(attname, flat=True)
.in_bulk({instance.pk for instance in instances})
)

for instance in instances:
    setattr(instance, attname, value_by_pk[instance.pk])

in_bulk() does not include primary keys for rows that no longer exist. Consequently, if one peer has been deleted, accessing the deferred field on another existing peer can raise KeyError.

The missing peer should not cause an unrelated peer's deferred-field access to fail.

If the instance being accessed itself has been deleted, its behavior should remain consistent with FETCH_ONE rather than producing a KeyError caused by another peer.

Expected behavior:

FETCH_PEERS should not overwrite a peer's field if that value has already been assigned locally.

A peer whose database row no longer exists should not cause deferred-field access on other existing peers to fail.

The behavior for the instance whose own row has been deleted should remain consistent with FETCH_ONE.

I reproduced both behaviors against Django 6.1. The corresponding FETCH_ONE cases behave correctly.

This appears to be a missing state check in DeferredAttribute.fetch_many(): it unconditionally assigns the fetched value to every peer and assumes that every requested primary key is present in the result returned by in_bulk().

Change History (10)

comment:1 by Vishy, 3 weeks ago

Triage Stage: Unreviewed → Accepted

Both issues were reviewed with the following tests,

  • tests/defer/tests.py

    a b class DeferTests(AssertionMixin, TestCase):  
    194194        with self.assertNumQueries(0):
    195195            p2.value
    196196
     197    def test_defer_fetch_mode_fetch_peers_preserves_locally_assigned_value(self):
     198        p1, p2 = Primary.objects.fetch_mode(FETCH_PEERS).defer("value")
     199        p1.value = "unsaved assignment"
     200
     201        # Accessing deferred field on p2 should not overwrite p1's local changes.
     202        self.assertEqual(p2.value, "yy")
     203        self.assertEqual(p1.value, "unsaved assignment")
     204
     205    def test_defer_fetch_mode_fetch_peers_deleted_peer(self):
     206        p1, p2 = Primary.objects.fetch_mode(FETCH_PEERS).defer("value")
     207
     208        # Delete p1 from the database while its instance remains in memory.
     209        Primary.objects.filter(pk=p1.pk).delete()
     210
     211        # Fetching deferred attribute on p2 should succeed and not raise
     212        # KeyError for the missing p1.pk in in_bulk().
     213        self.assertEqual(p2.value, "yy")
     214
    197215    def test_defer_fetch_mode_fetch_peers_single(self):
    198216        p1 = Primary.objects.fetch_mode(FETCH_PEERS).defer("value").get(name="p1")
    199217        with self.assertNumQueries(1):

comment:2 by Patel Namraa, 3 weeks ago

Has patch: set

comment:3 by Jacob Walls, 2 weeks ago

Description: modified (diff)
Owner: changed from Patel Namraa to Adam Johnson
Patch needs improvement: set
Severity: Normal → Release blocker

Thanks for the reports. Next time, please present separate reports for separate bugs. Also, for the second issue, it would have been helpful to include the reproducer instead of just reasoning about the code.

I feel more sure about accepting the issue for the KeyError in in_bulk().

I suppose we can also accept the issue for the "don't fetch over an already set value", but I'll CC Adam to see if they have any opinions on that. The proposal is coherent with how .defer() works today -- accessing doesn't overwrite.

comment:4 by Jacob Walls, 2 weeks ago

Cc: Adam Johnson added
Owner: changed from Adam Johnson to Patel Namraa

Sorry, I mixed up owner and cc fields.

comment:5 by Jacob Walls, 9 days ago

Left a question on the PR about whether we truly want to raise DoesNotExist at the moment of accessing a deferred attribute if *any* peer has been deleted, rather than just for the one whose attribute is being accessed. It seems stricter than the "fetch peers" behavior for foreign keys.

I haven't checked GenericRelation.

comment:6 by Jacob Walls, 6 days ago

Patch needs improvement: unset
Triage Stage: Accepted → Ready for checkin

My concern was the result of an incorrect test case.

comment:7 by Jacob Walls <jacobtylerwalls@…>, 6 days ago

Resolution: → fixed
Status: assigned → closed

In 6b212c2:

Fixed #37436 -- Avoided overwriting when FETCH_PEERS fetches deferred fields.

If a deferred field has been plucked via refresh_from_db(fields=...)
or modified by assigning an attribute, it shouldn't be overwritten when
attribute access on a peer triggers a fetch_many().

The peer fetching should also tolerate deleted instances.

Co-authored-by: Vishy <vishyzero@…>

comment:8 by Jacob Walls <jacobtylerwalls@…>, 6 days ago

In a92be5c4:

[6.1.x] Fixed #37436 -- Avoided overwriting when FETCH_PEERS fetches deferred fields.

If a deferred field has been plucked via refresh_from_db(fields=...)
or modified by assigning an attribute, it shouldn't be overwritten when
attribute access on a peer triggers a fetch_many().

The peer fetching should also tolerate deleted instances.

Co-authored-by: Vishy <vishyzero@…>

Backport of 6b212c29e7dfa94f92110d5be034033f449a0cf2 from main.

comment:9 by Adam Johnson, 5 days ago

Thanks for the report and CCing me.

The overwriting fix looks good to me.

Raising DoesNotExist for now-missing rows feels a bit icky to me, but I guess it's better than continuing with partial state. Maybe the error message could be improved by including the PK of the missing row, because right now it might look like the initial instance has gone missing, rather than a peer?

comment:10 by Jacob Walls, 4 days ago

Thanks for looking, Adam. I might have confused things in my earlier messages, and possibly in my editing of the commit message.

My latest understanding is that we did *not* introduce DoesNotExist for a deleted peer when fetching this.deferred. We prevented a KeyError after in_bulk(), and the manual raising of DoesNotExist is so that this.deferred doesn't misleadingly return None.

Note: See TracTickets for help on using tickets.
Back to Top