Adds bulk-import option for "import-records" task (#12792)

* skip plugin events if importing or migrating data

* Add bulkloaddata option

* Skip signals if importing

* Improve bulkloaddata command

* Optionally rebuild thumbnails

* enhancements for import_records task

* cache natural key references in bulkloaddata

* wrap export_records in @state_logger

* Reduce file size of exported data

* Added docs

* Test bulk workflow as part of CI

* Add progress bar for data import

* fix for import workflow bug

* Separately test bulk import workflow

* Exercise --prettify option

* Additional CI checks for content excludes

* Allow plugin loading for list_apps

* Additional CI unit tests

* Test for importing with conflicting records

* path fixes

* Adjust test conditions
This commit is contained in:
Oliver
2026-09-06 19:58:52 +10:00
committed by GitHub
parent 321489a41b
commit 1218af7366
10 changed files with 816 additions and 27 deletions
@@ -0,0 +1,213 @@
"""Custom management command to load fixtures faster using bulk_create()."""
import time
from contextlib import contextmanager
from django.core.management.base import CommandError
from django.core.management.commands.loaddata import Command as LoadDataCommand
from django.core.serializers import base as serializers_base
from django.db import DatabaseError, IntegrityError, connections, router
import structlog
from tqdm import tqdm
logger = structlog.get_logger('inventree')
DEFAULT_BATCH_SIZE = 500
# Number of records to process between progress bar updates. Calling tqdm's
# update() for every single record adds measurable overhead of its own (well
# beyond just the display refresh) once there are a million or more of them -
# so update it in batches instead.
PROGRESS_UPDATE_INTERVAL = 100
class Command(LoadDataCommand):
"""Load fixtures using bulk_create() for improved performance.
Behaves like the built-in 'loaddata' command, with two differences to be
aware of:
- pre_save / post_save signals are not sent, and Model.save() / full_clean()
are bypassed entirely (this is a Django bulk_create() limitation).
- Multi-table inheritance is not supported by bulk_create() and will fail
loudly rather than being silently mishandled. Natural-key foreign key /
many-to-many resolution *is* supported (falling back to an individual,
non-bulk save for any row that needs it - see save_obj()) and is further
sped up by caching each resolved natural key for the life of the command
(see _cached_natural_keys()), since 'export_records' uses
--natural-foreign and a large fixture can have many rows referencing the
same handful of natural-keyed objects (e.g. stock.StockItemTracking.user
-> auth.User) - without caching, each one costs a separate DB query.
Based on the django forum thread:
- https://forum.djangoproject.com/t/feature-proposal-faster-fixture-loading-via-loaddata-command/36972/21
"""
def add_arguments(self, parser):
"""Add bulkloaddata-specific arguments, on top of loaddata's own."""
super().add_arguments(parser)
parser.add_argument(
'--batch-size',
type=int,
default=DEFAULT_BATCH_SIZE,
help=f'Number of records per bulk_create() batch (default: {DEFAULT_BATCH_SIZE})',
)
parser.add_argument(
'--ignore-conflicts',
action='store_true',
help='Skip records that violate a unique constraint, instead of raising an error',
)
def handle(self, *fixture_labels, **options):
"""Store bulk-loading options before delegating to the base command."""
self.batch_size = options['batch_size']
self.ignore_conflicts = options['ignore_conflicts']
self.pending_objs = {}
self.pending_progress = 0
connection = connections[options['database']]
self.query_count = 0
def count_queries(execute, sql, params, many, context):
"""Count every query executed against this connection, without the overhead of recording each query's SQL text (unlike e.g. CaptureQueriesContext)."""
self.query_count += 1
return execute(sql, params, many, context)
start_time = time.monotonic()
with (
connection.execute_wrapper(count_queries),
self._cached_natural_keys(),
tqdm(desc='Importing', unit=' records') as self.progress,
):
super().handle(*fixture_labels, **options)
self.progress.update(self.pending_progress)
elapsed = time.monotonic() - start_time
if self.verbosity >= 1:
self.stdout.write(
f'Executed {self.query_count} database queries in {elapsed:.2f}s'
)
@contextmanager
def _cached_natural_keys(self):
"""Cache natural-key foreign key resolutions for the duration of this block.
Django's deserializer (deserialize_fk_value) issues a fresh DB query
every time it resolves a natural-key FK reference, with no caching of
its own. A fixture with many rows referencing the same handful of
natural-keyed objects (e.g. thousands of stock.StockItemTracking rows
all pointing at a few auth.User accounts) would otherwise cost one
query per row instead of one query per distinct value.
"""
cache = {}
original = serializers_base.deserialize_fk_value
def cached_deserialize_fk_value(
field, field_value, using, handle_forward_references
):
default_manager = field.remote_field.model._default_manager
is_natural_key = (
field_value is not None
and hasattr(default_manager, 'get_by_natural_key')
and hasattr(field_value, '__iter__')
and not isinstance(field_value, str)
)
if not is_natural_key:
# Plain (non natural-key) FK values never reach a DB query in
# the first place - nothing to cache, delegate as normal
return original(field, field_value, using, handle_forward_references)
cache_key = (field.remote_field.model, using, tuple(field_value))
if cache_key in cache:
return cache[cache_key]
value = original(field, field_value, using, handle_forward_references)
# Only cache a fully-resolved value - a deferred lookup (the
# referenced object doesn't exist yet) may well succeed on a later
# call, once that object has actually been saved.
if value is not serializers_base.DEFER_FIELD:
cache[cache_key] = value
return value
serializers_base.deserialize_fk_value = cached_deserialize_fk_value
try:
yield
finally:
serializers_base.deserialize_fk_value = original
def save_obj(self, obj):
"""Buffer an object for bulk insertion, instead of saving it immediately."""
self.pending_progress += 1
if self.pending_progress >= PROGRESS_UPDATE_INTERVAL:
self.progress.update(self.pending_progress)
self.pending_progress = 0
if (
obj.object._meta.app_config in self.excluded_apps
or type(obj.object) in self.excluded_models
):
return False
if not router.allow_migrate_model(self.using, obj.object.__class__):
return False
self.models.add(obj.object.__class__)
if obj.deferred_fields:
# This object has an unresolved forward reference (e.g. a natural-key
# FK to an object that has not been saved yet - 'export_records' uses
# --natural-foreign, and e.g. auth.User defines a natural_key(), so
# this does occur in practice). It cannot be bulk_create()'d as-is, since
# the deferred field would be written as blank/null. The base loaddata()
# command already resolves and saves objects like this individually,
# via save_deferred_fields(), once every fixture file has been buffered
# (see 'handle' -> 'loaddata') - so just hand it off for that, rather
# than also bulk-inserting it here with the field left unresolved.
self.objs_with_deferred_fields.append(obj)
else:
self.pending_objs.setdefault(obj.object.__class__, []).append(obj)
return True
def flush_pending(self):
"""Bulk-create every object buffered so far, grouped by model."""
for model, objs in self.pending_objs.items():
try:
model._default_manager.db_manager(self.using).bulk_create(
[obj.object for obj in objs],
batch_size=self.batch_size,
ignore_conflicts=self.ignore_conflicts,
)
except (DatabaseError, IntegrityError, ValueError) as e:
e.args = (
f'Could not bulk-create {len(objs)} object(s) of {model._meta.label}: {e}',
)
raise
# bulk_create() cannot populate many-to-many relations - apply them here,
# same as DeserializedObject.save() does for the non-bulk path.
for obj in objs:
if obj.m2m_data:
for accessor_name, values in obj.m2m_data.items():
getattr(obj.object, accessor_name).set(values)
obj.m2m_data = None
self.pending_objs = {}
def load_label(self, fixture_label):
"""Load one fixture label, then flush the records it buffered."""
super().load_label(fixture_label)
try:
self.flush_pending()
except Exception as e:
if not isinstance(e, CommandError):
e.args = (f"Problem installing fixture '{fixture_label}': {e}",)
raise
+2 -2
View File
@@ -49,7 +49,7 @@ def isWaitingForDatabase():
def isImportingData():
"""Returns True if the database is currently importing (or exporting) data, e.g. 'loaddata' command is performed."""
return any(x in sys.argv for x in ['flush', 'loaddata', 'dumpdata'])
return any(x in sys.argv for x in ['flush', 'loaddata', 'bulkloaddata', 'dumpdata'])
def isRunningMigrations():
@@ -285,7 +285,7 @@ def canAppAccessDatabase(
excluded_commands.append('test')
if not allow_plugins:
excluded_commands.extend(['collectplugins'])
excluded_commands.extend(['collectplugins', 'list_apps'])
return all(cmd not in sys.argv for cmd in excluded_commands)
@@ -7,6 +7,7 @@ from pathlib import Path
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management import call_command
from django.db import IntegrityError
from django.test import TestCase
from opentelemetry.instrumentation.sqlite3 import SQLite3Instrumentor
@@ -116,6 +117,50 @@ class CommandTestCase(TestCase):
self.assertEqual(output, 'done')
self.assertEqual(my_admin3.authenticator_set.all().count(), 0)
def test_bulkloaddata(self):
"""Test the bulkloaddata command."""
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
# ContentType has no custom signals and a real unique_together constraint
# (app_label, model), making it a convenient, side-effect-free test model.
entries = [
ContentType.objects.create(app_label='bulkloaddata_test', model=f'model{i}')
for i in range(5)
]
pks = [e.pk for e in entries]
data = serializers.serialize('json', entries)
ContentType.objects.filter(pk__in=pks).delete()
tmp_file = get_testfolder_dir().joinpath('bulkloaddata_test.json')
tmp_file.write_text(data, encoding='utf-8')
try:
# Basic load - all records should be recreated
call_command('bulkloaddata', str(tmp_file), verbosity=0)
self.assertEqual(ContentType.objects.filter(pk__in=pks).count(), 5)
# Re-loading without --ignore-conflicts should raise (unique app_label/model)
with self.assertRaises(IntegrityError):
call_command('bulkloaddata', str(tmp_file), verbosity=0)
# Re-loading with --ignore-conflicts should succeed, without duplicating rows
call_command(
'bulkloaddata', str(tmp_file), verbosity=0, ignore_conflicts=True
)
self.assertEqual(ContentType.objects.filter(pk__in=pks).count(), 5)
# A small batch size should still load every record correctly
ContentType.objects.filter(pk__in=pks).delete()
call_command('bulkloaddata', str(tmp_file), verbosity=0, batch_size=2)
models = set(
ContentType.objects.filter(pk__in=pks).values_list('model', flat=True)
)
self.assertEqual(models, {e.model for e in entries})
finally:
ContentType.objects.filter(pk__in=pks).delete()
tmp_file.unlink(missing_ok=True)
def test_backup_metadata(self):
"""Test the backup metadata functions."""
from InvenTree.backup import (
+3
View File
@@ -4287,6 +4287,9 @@ class TransferOrderAllocation(models.Model):
def _touch_order_updated_at(instance):
"""Bump updated_at on the parent order without triggering a full save."""
if InvenTree.ready.isRunningMigrations() or InvenTree.ready.isImportingData():
# Do not touch the order during migrations or data import
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
return
instance.order.__class__.objects.filter(pk=instance.order_id).update(
@@ -14,7 +14,7 @@ from opentelemetry import trace
import InvenTree.exceptions
from common.settings import get_global_setting
from InvenTree.ready import canAppAccessDatabase, isImportingData
from InvenTree.ready import canAppAccessDatabase, isImportingData, isRunningMigrations
from InvenTree.tasks import bulk_offload_task, offload_task
from plugin import PluginMixinEnum
from plugin.registry import registry
@@ -227,6 +227,10 @@ def process_event(plugin_slug, event, *args, **kwargs):
This function is run by the background worker process.
This function may queue multiple functions to be handled by the background worker.
"""
if isRunningMigrations() or isImportingData():
# Do not trigger events during migrations or data import
return
plugin = registry.get_plugin(plugin_slug, active=True)
if plugin is None: # pragma: no cover
@@ -293,6 +297,10 @@ def allow_table_event(table_name):
@receiver(post_save)
def after_save(sender, instance, created, **kwargs):
"""Trigger an event whenever a database entry is saved."""
if isRunningMigrations() or isImportingData():
# Do not trigger events during migrations or data import
return
table = sender.objects.model._meta.db_table
instance_id = getattr(instance, 'id', None)
@@ -312,6 +320,10 @@ def after_save(sender, instance, created, **kwargs):
@receiver(post_delete)
def after_delete(sender, instance, **kwargs):
"""Trigger an event whenever a database entry is deleted."""
if isRunningMigrations() or isImportingData():
# Do not trigger events during migrations or data import
return
table = sender.objects.model._meta.db_table
if not allow_table_event(table):