Opened 3 weeks ago

Last modified 4 days ago

#37436 closed Bug

FETCH_PEERS can overwrite deferred-field changes and raise KeyError for deleted peers — at Version 3

Reported by: Patel Namraa Owned by: Adam Johnson
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 (3)

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.

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