Skip to content

post_save signal does not get disconnected when class gets unreferenced #233

@sjoerdjob

Description

@sjoerdjob

Describe the bug
While running Django migrations, it can happen that subclasses of DirtyFieldsMixin get created (and later unreferenced, and thus destroyed). When a class gets unreferenced, the location that class holds in memory gets cleared up again, and a new Python object could be instantiated in that location. That new Python object might be another model class.

To Reproduce

See the attached project for a minimal example.
Running python manage.py migrate (after having installed the latest version of Django and django-dirtyfields) triggers the issue most of the time.

minimal.tar.gz

Expected behavior
Migrations always run completely pass.

Actual Behavior

Traceback (most recent call last):
  File "/home/sjoerdjobpostmus/dev/minimal/./manage.py", line 22, in <module>
    main()
    ~~~~^^
  File "/home/sjoerdjobpostmus/dev/minimal/./manage.py", line 18, in main
    execute_from_command_line(sys.argv)
    ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
    ~~~~~~~~~~~~~~~^^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/base.py", line 413, in run_from_argv
    self.execute(*args, **cmd_options)
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/base.py", line 459, in execute
    output = self.handle(*args, **options)
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/base.py", line 107, in wrapper
    res = handle_func(*args, **kwargs)
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/core/management/commands/migrate.py", line 357, in handle
    post_migrate_state = executor.migrate(
        targets,
    ...<3 lines>...
        fake_initial=fake_initial,
    )
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/migrations/executor.py", line 135, in migrate
    state = self._migrate_all_forwards(
        state, plan, full_plan, fake=fake, fake_initial=fake_initial
    )
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/migrations/executor.py", line 167, in _migrate_all_forwards
    state = self.apply_migration(
        state, migration, fake=fake, fake_initial=fake_initial
    )
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/migrations/executor.py", line 255, in apply_migration
    state = migration.apply(state, schema_editor)
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/migrations/migration.py", line 132, in apply
    operation.database_forwards(
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        self.app_label, schema_editor, old_state, project_state
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/migrations/operations/special.py", line 196, in database_forwards
    self.code(from_state.apps, schema_editor)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/sjoerdjobpostmus/dev/minimal/stress/migrations/0002_auto_20241119_1734.py", line 28, in update_withoutdirty_instance
    inst.save()
    ~~~~~~~~~^^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/models/base.py", line 891, in save
    self.save_base(
    ~~~~~~~~~~~~~~^
        using=using,
        ^^^^^^^^^^^^
    ...<2 lines>...
        update_fields=update_fields,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/db/models/base.py", line 1012, in save_base
    post_save.send(
    ~~~~~~~~~~~~~~^
        sender=origin,
        ^^^^^^^^^^^^^^
    ...<4 lines>...
        using=using,
        ^^^^^^^^^^^^
    )
    ^
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/django/dispatch/dispatcher.py", line 189, in send
    response = receiver(signal=self, sender=sender, **named)
  File "/home/sjoerdjobpostmus/.virtualenvs/minimal/lib/python3.13/site-packages/dirtyfields/dirtyfields.py", line 172, in reset_state
    new_state = instance._as_dict(check_relationship=True)
                ^^^^^^^^^^^^^^^^^
AttributeError: 'WithoutDirty' object has no attribute '_as_dict'

Environment (please complete the following information):

  • OS: Debian (but also noticed under Ubuntu)
  • Python version: 3.12 and 3.13.
  • Django version: 4.2+
  • django-dirtyfields version: 1.9.5 and newer.

Additional context

A related Django issue is https://code.djangoproject.com/ticket/35801#comment:6 . I'm not sure if either of the codebases can fix this bug separately though, or whether it requires changes in both projects.

Suggested improvements:

  • add a class-attribute registered_post_save_signal (or something) to make sure post_save.connect is only called once per model class.
  • In the code that is calling post_save.connect, also add 'something' that makes sure that post_save.disconnect(reset_state, sender=self.__class__, dispatch_uid=...) gets called whenever the model class (not instance) gets unreferenced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions