Skip to content

Bulk Data-Change Notification Recipe

This recipe batches a multi-row write while preserving the transaction boundary seen by GraphQL and RemoteAPI clients:

from django.db import transaction

from general_manager.api import bulk_data_change_notifications
from myapp.managers import Project


def archive_active_projects() -> None:
    with bulk_data_change_notifications():
        with transaction.atomic():
            for project in Project.filter(status="active"):
                project.update(status="archived")

The outer context emits one refresh event per affected GraphQL manager class or RemoteAPI resource after the transaction block exits. Each event has identification = null and a UUID4 event_id; clients should refetch their resource or rehydrate their subscribed item. The context does not replace cache invalidation or other per-write signals.

Keep the notification context outside any pre-existing outer transaction. It flushes queued targets even when the body raises, then re-raises the body exception. If both the body and notification flush fail, the context raises a BaseExceptionGroup containing both failures. Ordinary channel-layer send errors are logged while other queued targets continue flushing.

To observe the surrounding ORM transaction lifecycle, register a started receiver and collect changed classes from the ordinary post-change signal:

from django.db import transaction

from general_manager.cache.data_change_context import register_data_change_class
from general_manager.cache.signals import (
    data_change_transaction_started,
    post_data_change,
)


def started(sender, transaction_context, database_alias, **kwargs):
    transaction_context.metadata["batch"] = begin_coordination()
    transaction.on_commit(
        lambda: finish_coordination(transaction_context.metadata["batch"]),
        using=database_alias,
    )


def changed(sender, database_alias, **kwargs):
    register_data_change_class(sender.__name__, database_alias)


data_change_transaction_started.connect(started, weak=False)
post_data_change.connect(changed, weak=False)

data_change_transaction_finished reports committed for GeneralManager's own block or savepoint and rolled_back for failures. Register durable completion work unconditionally with transaction.on_commit(using=database_alias); it runs after GeneralManager's transaction commits or, when the caller owns an outer transaction, after that outer transaction commits.

register_data_change_class() returns False for a caller-owned outer transaction and does not add the class to transaction_context.changed_classes. In that case, collect changed class names in caller-owned transaction state and consume that state from an on_commit(using=database_alias) callback registered on the outer transaction.

Skip redundant refreshes

An unchanged built-in ORM update does not emit post_data_change, so it does not queue a GraphQL/RemoteAPI refresh, search dispatch, or workflow event. An explicit audit touch opts back into persistence and notification:

def review_project(project: Project) -> None:
    with bulk_data_change_notifications():
        project.update(status=project.status, ignore_permission=True)
        project.update(
            status=project.status,
            history_comment="Reviewed without changing fields",
            ignore_permission=True,
        )

The first update is a true no-op. The second records history and emits the ordinary post-change event even though its field values are equal. Validation, pre-change receivers, and transaction cleanup still run for both calls.