Skip to content

Batch Data-Change Notifications

Use bulk_data_change_notifications() when one operation updates many managers and GraphQL or RemoteAPI clients should receive one aggregate refresh per affected resource instead of one row-level event per write.

Wrap the outermost transaction

Import the context from the stable API module and place it outside the true outermost database transaction:

from django.db import transaction

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


with bulk_data_change_notifications():
    with transaction.atomic():
        for project in Project.filter(status="active"):
            project.update(status="archived")

GraphQL and RemoteAPI receive post_data_change immediately, but their websocket publishers schedule delivery with transaction.on_commit() on the changed manager's database alias. With the order above, the outermost commit runs those callbacks while the notification context remains open; its exit then flushes one aggregate refresh per target. A rollback, including a savepoint rollback, discards the callbacks, so no refresh enters the batch.

If another layer already encloses this code in transaction.atomic()—including Django's ATOMIC_REQUESTS—place the notification context outside that enclosing block so it remains open through the actual commit rather than an intermediate savepoint.

The reverse order is still commit-safe but does not guarantee aggregation:

with transaction.atomic():
    with bulk_data_change_notifications():
        for project in Project.filter(status="active"):
            project.update(status="archived")

Here the batch closes before the outer transaction runs its commit callbacks, so those callbacks publish ordinary row-level notifications instead of queuing aggregate refreshes.

What clients receive

Inside the context, GraphQL and RemoteAPI delivery is deduplicated by target:

  • GraphQL detail subscriptions receive action = "refresh" and rehydrate the subscribed item. Class-wide subscriptions receive item = null because the event identifies no row.
  • RemoteAPI clients receive one websocket event per affected resource with action = "refresh" and identification = null. They should requery the resource over REST.

Each aggregate event includes a UUID4 event_id. The context does not reveal which rows changed, how many changed, or the original row-level actions. Cache invalidation and unrelated signal receivers still run for each write. For ordinary identified row-level GraphQL class events, subscriptions hydrate the changed object and call can_read_instance() only after commit, so a create cannot race pre-commit hydration. Aggregate batch refresh events have no identification, yield item = null, and are exempt from object-level permission hydration and checking. RemoteAPI delivery after commit is best-effort: unavailable channel layers produce no message, and channel-layer failures are logged rather than raised.

Nesting behavior

Nested notification contexts join the already-active outer batch instead of flushing independently. Outside the context, existing row-level notification delivery remains commit-bound and rollback-safe.

Observe the data-change lifecycle

Use the lifecycle signals when an integration needs to coordinate work around the outermost ORM mutation, rather than around every nested pre_data_change or post_data_change callback:

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 on_transaction_started(sender, transaction_context, database_alias, **kwargs):
    transaction_context.metadata["consumer"] = begin_coordination()
    transaction.on_commit(
        lambda: finish_coordination(transaction_context.metadata["consumer"]),
        using=database_alias,
    )


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


data_change_transaction_started.connect(on_transaction_started, weak=False)
post_data_change.connect(on_manager_changed, weak=False)

data_change_transaction_finished reports committed when GeneralManager's own block or savepoint exits successfully and rolled_back otherwise. Register completion unconditionally with transaction.on_commit(using=database_alias) as shown so it follows either GeneralManager's commit or the caller-owned outer commit. Nested same-alias mutations share one lifecycle context, and transaction_context.changed_classes deduplicates the classes registered by the post-change receiver when GeneralManager owns the transaction.

When the caller already owns the outer transaction, register_data_change_class() returns False without adding the class to transaction_context.changed_classes. Consumers that need outer-transaction aggregation must instead collect class names in caller-owned transaction state and consume them from an on_commit(using=database_alias) callback registered on that outer transaction.

Avoid downstream work for no-op updates

Built-in ORM managers compare the validated field values and requested relation sets before saving. When an ordinary update is proven unchanged, it performs the permission, validation, pre-change, cache, and transaction-lifecycle work but emits no post_data_change. As a result, this notification context does not queue a refresh for that update, and the same no-op does not create search reindex work or workflow/outbox records. Custom validation and pre-change receivers can still have side effects, and custom mutation overrides retain their normal post-change behavior because the framework cannot prove them to be no-ops.

Pass a nonempty history_comment when an explicit audit touch should persist and publish a change event even though the model values are equal. The ORM interface concept and runnable recipe show the corresponding write patterns.

For the full callable signature and exception contract, see the GraphQL API reference. The cookbook recipe is a compact version suitable for adapting into a service function.