diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 31b410f3be4..59cb682763b 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -247,7 +247,6 @@ const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => { return Boolean(baseIsBlocked || pathHasBlockedSystem); }; - const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) => Object.keys(schema.domainLevelIds).includes( queryFieldSpec.baseTable.name.toLowerCase() as 'collection' diff --git a/specifyweb/specify/migrations/0001_initial.py b/specifyweb/specify/migrations/0001_initial.py index 5c037acb8e4..4db672d4f4a 100644 --- a/specifyweb/specify/migrations/0001_initial.py +++ b/specifyweb/specify/migrations/0001_initial.py @@ -5762,6 +5762,36 @@ class Migration(migrations.Migration): 'ordering': (), }, ), + migrations.CreateModel( + name='AutonumschColl', + fields=[ + ('collectionid', models.ForeignKey(db_column='CollectionID', on_delete=django.db.models.deletion.DO_NOTHING, primary_key=True, serialize=False, to='specify.collection')), + ], + options={ + 'db_table': 'autonumsch_coll', + 'managed': False, + }, + ), + migrations.CreateModel( + name='AutonumschDiv', + fields=[ + ('divisionid', models.ForeignKey(db_column='DivisionID', on_delete=django.db.models.deletion.DO_NOTHING, primary_key=True, serialize=False, to='specify.division')), + ], + options={ + 'db_table': 'autonumsch_div', + 'managed': False, + }, + ), + migrations.CreateModel( + name='AutonumschDsp', + fields=[ + ('disciplineid', models.ForeignKey(db_column='DisciplineID', on_delete=django.db.models.deletion.DO_NOTHING, primary_key=True, serialize=False, to='specify.discipline')), + ], + options={ + 'db_table': 'autonumsch_dsp', + 'managed': False, + }, + ), migrations.CreateModel( name='Author', fields=[ diff --git a/specifyweb/specify/migrations/0042_add_indexes_to_help_autonumbering.py b/specifyweb/specify/migrations/0042_add_indexes_to_help_autonumbering.py new file mode 100644 index 00000000000..6a5636cbfd7 --- /dev/null +++ b/specifyweb/specify/migrations/0042_add_indexes_to_help_autonumbering.py @@ -0,0 +1,145 @@ +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ('specify', '0041_add_missing_schema_after_reorganization'), + ] + + operations = [ + # accession, accessionnumber, division + migrations.AddIndex( + model_name='accession', + index=models.Index(fields=['division_id', 'accessionnumber'], name='DivAccessionNumberIDX'), + ), + # collectionobject, catalognumber, collection + migrations.AddIndex( + model_name='collectionobject', + index=models.Index(fields=['collectionmemberid', 'catalognumber'], name='ColCatalogNumberIDX'), + ), + # loan, discipline, loannumber + migrations.AddIndex( + model_name='loan', + index=models.Index(fields=['discipline', 'loannumber'], name='DispLoanNumberIDX'), + ), + + # borrow, invoicenumber, null + # migrations.AddIndex( + # model_name='borrow', + # index=models.Index(fields=['invoicenumber'], name='BorrowInvoiceNumberIDX'), + # ), + + # exchangein, exchangeinnumber, division + migrations.AddIndex( + model_name='exchangein', + index=models.Index(fields=['division', 'exchangeinnumber'], name='DivExcInNumberIDX'), + ), + + # exchangeout, exchangeoutnumber, division + migrations.AddIndex( + model_name='exchangeout', + index=models.Index(fields=['division', 'exchangeoutnumber'], name='DivExcOutNumberIDX'), + ), + + # collectingevent, stationfieldnumber, discipline + migrations.AddIndex( + model_name='collectingevent', + index=models.Index(fields=['discipline', 'stationfieldnumber'], name='DispStationFieldNumIDX'), + ), + + # collection, regnumber, discipline + migrations.AddIndex( + model_name='collection', + index=models.Index(fields=['discipline', 'regnumber'], name='DispColRegNumberIDX'), + ), + + # collector, ordernumber, division + migrations.AddIndex( + model_name='collector', + index=models.Index(fields=['division', 'ordernumber'], name='DivCollectorOrderNumIDX'), + ), + + # discipline, regnumber, division + migrations.AddIndex( + model_name='discipline', + index=models.Index(fields=['division', 'regnumber'], name='DivDispRegNumberIDX'), + ), + + # disposal, disposalnumber, null + # migrations.AddIndex( + # model_name='disposal', + # index=models.Index(fields=['disposalnumber'], name='DisposalNumberIDX'), + # ), + + # division, regnumber, institution + migrations.AddIndex( + model_name='division', + index=models.Index(fields=['institution', 'regnumber'], name='InstDivRegNumberIDX'), + ), + + # exsiccataitem, number, null + migrations.AddIndex( + model_name='exsiccataitem', + index=models.Index(fields=['number'], name='ExsiccataItemNumberIDX'), + ), + + # fieldnotebookpage, pagenumber, discipline + migrations.AddIndex( + model_name='fieldnotebookpage', + index=models.Index(fields=['discipline', 'pagenumber'], name='DispFNBPageNumIDX'), + ), + + # fieldnotebookpageset, ordernumber, discipline + migrations.AddIndex( + model_name='fieldnotebookpageset', + index=models.Index(fields=['discipline', 'ordernumber'], name='DispFNBPageSetNumIDX'), + ), + + # fundingagent, ordernumber, division + migrations.AddIndex( + model_name='fundingagent', + index=models.Index(fields=['division', 'ordernumber'], name='DivFundingAgentOrderNumIDX'), + ), + + # gift, giftnumber, discipline + migrations.AddIndex( + model_name='gift', + index=models.Index(fields=['discipline', 'giftnumber'], name='DispGiftNumberIDX'), + ), + + # groupperson, ordernumber, division + migrations.AddIndex( + model_name='groupperson', + index=models.Index(fields=['division', 'ordernumber'], name='DivGroupPersonOrderNumIDX'), + ), + + # permit, permitnumber, institution + migrations.AddIndex( + model_name='permit', + index=models.Index(fields=['institution', 'permitnumber'], name='InstPermitNumberIDX'), + ), + + # referencework, librarynumber, institution + migrations.AddIndex( + model_name='referencework', + index=models.Index(fields=['institution', 'librarynumber'], name='InstRefWorkLibNumIDX'), + ), + + # repositoryagreement, repositoryagreementnumber, division + migrations.AddIndex( + model_name='repositoryagreement', + index=models.Index(fields=['division', 'repositoryagreementnumber'], name='DivRepoAgreeNumIDX'), + ), + + # shipment, shipmentnumber, discipline + migrations.AddIndex( + model_name='shipment', + index=models.Index(fields=['discipline', 'shipmentnumber'], name='DispShipmentNumberIDX'), + ), + + # treatmentevent, treatmentnumber, division + migrations.AddIndex( + model_name='treatmentevent', + index=models.Index(fields=['division', 'treatmentnumber'], name='DivTreatmentNumberIDX'), + ), + ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 4e991d02ecb..59c0fe2d135 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -85,7 +85,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['accessionnumber'], name='AccessionNumberIDX'), - models.Index(fields=['dateaccessioned'], name='AccessionDateIDX') + models.Index(fields=['dateaccessioned'], name='AccessionDateIDX'), + models.Index(fields=['division_id', 'accessionnumber'], name='DivAccessionNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -792,7 +793,7 @@ class Meta: indexes = [ models.Index(fields=['invoicenumber'], name='BorInvoiceNumberIDX'), models.Index(fields=['receiveddate'], name='BorReceivedDateIDX'), - models.Index(fields=['collectionmemberid'], name='BorColMemIDX') + models.Index(fields=['collectionmemberid'], name='BorColMemIDX'), ] @@ -989,7 +990,8 @@ class Meta: models.Index(fields=['startdate'], name='CEStartDateIDX'), models.Index(fields=['enddate'], name='CEEndDateIDX'), models.Index(fields=['uniqueidentifier'], name='CEUniqueIdentifierIDX'), - models.Index(fields=['guid'], name='CEGuidIDX') + models.Index(fields=['guid'], name='CEGuidIDX'), + models.Index(fields=['discipline', 'stationfieldnumber'], name='DispStationFieldNumIDX'), # composite index for autonumbering range/gap locks ] @@ -1386,7 +1388,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['collectionname'], name='CollectionNameIDX'), - models.Index(fields=['guid'], name='CollectionGuidIDX') + models.Index(fields=['guid'], name='CollectionGuidIDX'), + models.Index(fields=['discipline', 'regnumber'], name='DispColRegNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -1490,7 +1493,8 @@ class Meta: models.Index(fields=['uniqueidentifier'], name='COUniqueIdentifierIDX'), models.Index(fields=['altcatalognumber'], name='AltCatalogNumberIDX'), models.Index(fields=['guid'], name='ColObjGuidIDX'), - models.Index(fields=['collectionmemberid'], name='COColMemIDX') + models.Index(fields=['collectionmemberid'], name='COColMemIDX'), + models.Index(fields=['collectionmemberid', 'catalognumber'], name='ColCatalogNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -2010,7 +2014,8 @@ class Meta: ordering = ('ordernumber',) unique_together = (('agent', 'collectingevent'),) indexes = [ - models.Index(fields=['division'], name='COLTRDivIDX') + models.Index(fields=['division'], name='COLTRDivIDX'), + models.Index(fields=['division', 'ordernumber'], name='DivCollectorOrderNumIDX'), # composite index for autonumbering range/gap locks ] @@ -2669,7 +2674,7 @@ class Meta: db_table = 'deaccession' ordering = () indexes = [ - models.Index(fields=['deaccessionnumber'], name='DeaccessionNumberIDX'), + models.Index(fields=['deaccessionnumber'], name='DeaccessionNumberIDX'), # composite index for autonumbering range/gap locks models.Index(fields=['deaccessiondate'], name='DeaccessionDateIDX') ] @@ -2898,7 +2903,8 @@ class Meta: db_table = 'discipline' ordering = () indexes = [ - models.Index(fields=['name'], name='DisciplineNameIDX') + models.Index(fields=['name'], name='DisciplineNameIDX'), + models.Index(fields=['division', 'regnumber'], name='DivDispRegNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -2935,7 +2941,7 @@ class Meta: db_table = 'disposal' ordering = () indexes = [ - models.Index(fields=['disposalnumber'], name='DisposalNumberIDX'), + models.Index(fields=['disposalnumber'], name='DisposalNumberIDX'), # composite index for autonumbering range/gap locks models.Index(fields=['disposaldate'], name='DisposalDateIDX') ] @@ -3052,7 +3058,8 @@ class Meta: db_table = 'division' ordering = () indexes = [ - models.Index(fields=['name'], name='DivisionNameIDX') + models.Index(fields=['name'], name='DivisionNameIDX'), + models.Index(fields=['institution', 'regnumber'], name='InstDivRegNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -3096,7 +3103,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['exchangedate'], name='ExchangeDateIDX'), - models.Index(fields=['descriptionofmaterial'], name='DescriptionOfMaterialIDX') + models.Index(fields=['descriptionofmaterial'], name='DescriptionOfMaterialIDX'), + models.Index(fields=['division', 'exchangeinnumber'], name='DivExcInNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -3202,7 +3210,8 @@ class Meta: indexes = [ models.Index(fields=['exchangedate'], name='ExchangeOutdateIDX'), # models.Index(fields=['DescriptionOfMaterial'], name='DescriptionOfMaterialIDX2'), - models.Index(fields=['exchangeoutnumber'], name='ExchangeOutNumberIDX') + models.Index(fields=['exchangeoutnumber'], name='ExchangeOutNumberIDX'), + models.Index(fields=['division', 'exchangeoutnumber'], name='DivExcOutNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -3316,6 +3325,10 @@ class Exsiccataitem(models.Model): class Meta: db_table = 'exsiccataitem' ordering = () + indexes = [ + models.Index(fields=['number'], name='exsiccataitem_number_idx'), + models.Index(fields=['number'], name='ExsiccataItemNumberIDX'), # composite index for autonumbering range/gap locks + ] save = partialmethod(custom_save) @@ -3437,7 +3450,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['pagenumber'], name='FNBPPageNumberIDX'), - models.Index(fields=['scandate'], name='FNBPScanDateIDX') + models.Index(fields=['scandate'], name='FNBPScanDateIDX'), + models.Index(fields=['discipline', 'pagenumber'], name='DispFNBPageNumIDX'), # composite index for autonumbering range/gap locks ] @@ -3497,7 +3511,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['startdate'], name='FNBPSStartDateIDX'), - models.Index(fields=['enddate'], name='FNBPSEndDateIDX') + models.Index(fields=['enddate'], name='FNBPSEndDateIDX'), + models.Index(fields=['discipline', 'ordernumber'], name='DispFNBPageSetNumIDX'), # composite index for autonumbering range/gap locks ] @@ -3556,7 +3571,8 @@ class Meta: ordering = () unique_together = (('agent', 'collectingtrip'),) indexes = [ - models.Index(fields=['division'], name='COLTRIPDivIDX') + models.Index(fields=['division'], name='COLTRIPDivIDX'), + models.Index(fields=['division', 'ordernumber'], name='DivFundingAgentOrderNumIDX'), # composite index for autonumbering range/gap locks ] @@ -3894,7 +3910,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['giftnumber'], name='GiftNumberIDX'), - models.Index(fields=['giftdate'], name='GiftDateIDX') + models.Index(fields=['giftdate'], name='GiftDateIDX'), + models.Index(fields=['discipline', 'giftnumber'], name='DispGiftNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -4022,6 +4039,9 @@ class Meta: db_table = 'groupperson' ordering = () unique_together = (('ordernumber', 'group'),) + indexes = [ + models.Index(fields=['division', 'ordernumber'], name='DivGroupPersonOrderNumIDX'), # composite index for autonumbering range/gap locks + ] save = partialmethod(custom_save) @@ -4396,7 +4416,8 @@ class Meta: indexes = [ models.Index(fields=['loannumber'], name='LoanNumberIDX'), models.Index(fields=['loandate'], name='LoanDateIDX'), - models.Index(fields=['currentduedate'], name='CurrentDueDateIDX') + models.Index(fields=['currentduedate'], name='CurrentDueDateIDX'), + models.Index(fields=['discipline', 'loannumber'], name="DispLoanNumberIDX"), # composite index for autonumbering range/gap locks ] @@ -5038,7 +5059,8 @@ class Meta: ordering = () indexes = [ models.Index(fields=['permitnumber'], name='PermitNumberIDX'), - models.Index(fields=['issueddate'], name='IssuedDateIDX') + models.Index(fields=['issueddate'], name='IssuedDateIDX'), + models.Index(fields=['institution', 'permitnumber'], name='InstPermitNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -5695,7 +5717,8 @@ class Meta: models.Index(fields=['title'], name='RefWrkTitleIDX'), models.Index(fields=['publisher'], name='RefWrkPublisherIDX'), models.Index(fields=['guid'], name='RefWrkGuidIDX'), - models.Index(fields=['isbn'], name='ISBNIDX') + models.Index(fields=['isbn'], name='ISBNIDX'), + models.Index(fields=['institution', 'librarynumber'], name='InstRefWorkLibNumIDX'), # composite index for autonumbering range/gap locks ] @@ -5763,6 +5786,7 @@ class Meta: ordering = () indexes = [ models.Index(fields=['repositoryagreementnumber'], name='RefWrkNumberIDX'), + models.Index(fields=['division', 'repositoryagreementnumber'], name='DivRepoAgreeNumIDX'), # composite index for autonumbering range/gap locks # models.Index(fields=['StartDate'], name='RefWrkStartDate') ] @@ -5838,7 +5862,8 @@ class Meta: models.Index(fields=['shipmentnumber'], name='ShipmentNumberIDX'), models.Index(fields=['shipmentdate'], name='ShipmentDateIDX'), models.Index(fields=['discipline'], name='ShipmentDspMemIDX'), - models.Index(fields=['shipmentmethod'], name='ShipmentMethodIDX') + models.Index(fields=['shipmentmethod'], name='ShipmentMethodIDX'), + models.Index(fields=['discipline', 'shipmentnumber'], name='DispShipmentNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -7226,7 +7251,8 @@ class Meta: models.Index(fields=['datereceived'], name='TEDateReceivedIDX'), models.Index(fields=['datetreatmentstarted'], name='TEDateTreatmentStartedIDX'), models.Index(fields=['fieldnumber'], name='TEFieldNumberIDX'), - models.Index(fields=['treatmentnumber'], name='TETreatmentNumberIDX') + models.Index(fields=['treatmentnumber'], name='TETreatmentNumberIDX'), + models.Index(fields=['division', 'treatmentnumber'], name='DivTreatmentNumberIDX'), # composite index for autonumbering range/gap locks ] @@ -7988,4 +8014,4 @@ class Meta: db_table = 'tectonicunit' ordering = () - save = partialmethod(custom_save) \ No newline at end of file + save = partialmethod(custom_save) diff --git a/specifyweb/specify/models_utils/lock_tables.py b/specifyweb/specify/models_utils/lock_tables.py index e6c2e75fbce..fd089984a62 100644 --- a/specifyweb/specify/models_utils/lock_tables.py +++ b/specifyweb/specify/models_utils/lock_tables.py @@ -1,10 +1,9 @@ -from django.db import connection +from django.db import connection, transaction from contextlib import contextmanager import logging logger = logging.getLogger(__name__) - @contextmanager def lock_tables(*tables): cursor = connection.cursor() @@ -13,8 +12,63 @@ def lock_tables(*tables): yield else: try: + # NOTE: Should not do within a transaction.atomic() block + # NOTE: See PRs #6490 and #7455 + # - https://github.com/specify/specify7/issues/6490#issuecomment-3020675840 + # - https://github.com/specify/specify7/issues/6490#issuecomment-3340619060 + # - https://github.com/specify/specify7/pull/7455#issue-3459218457 cursor.execute('lock tables %s' % ' write, '.join(tables) + ' write') yield finally: cursor.execute('unlock tables') + +@contextmanager +def mysql_named_lock(lock_name: str, timeout: int = 10): + """ + Connection-scoped mutex using MySQL GET_LOCK/RELEASE_LOCK. + Safe to use inside transaction.atomic(). + """ + if connection.vendor != "mysql": + yield + return + + with connection.cursor() as cur: + cur.execute("SELECT GET_LOCK(%s, %s)", [lock_name, timeout]) + got = cur.fetchone()[0] + + if not got: + with connection.cursor() as cur: + cur.execute("SELECT IS_USED_LOCK(%s)", [lock_name]) + locking_db_process_id = cur.fetchone()[0] + if locking_db_process_id is not None: + logger.warning( + "Failed to acquire lock %r. It is currently held by DB Process %d.", + lock_name, + locking_db_process_id + ) + else: + logger.error( + "Failed to acquire lock %r after timeout, but IS_USED_LOCK() reports it is FREE. Check for internal MySQL/connection issues.", + lock_name + ) + raise TimeoutError(f"Could not acquire MySQL named lock {lock_name!r}") + + try: + yield + finally: + try: + with connection.cursor() as cur: + cur.execute("SELECT RELEASE_LOCK(%s)", [lock_name]) + except Exception: + logger.info("Failed to release MySQL named lock %r", lock_name, exc_info=True) + +def get_autonumbering_lock_name(db_name, table_name): + return f"autonumbering:{db_name.lower()}:{table_name.lower()}" + +@contextmanager +def autonumbering_lock_table(db_name, table_name): + lock_name = get_autonumbering_lock_name(db_name, table_name) + with mysql_named_lock(lock_name): + yield + diff --git a/specifyweb/specify/utils/autonumbering.py b/specifyweb/specify/utils/autonumbering.py index e1966d4e117..b465268b9c7 100644 --- a/specifyweb/specify/utils/autonumbering.py +++ b/specifyweb/specify/utils/autonumbering.py @@ -4,13 +4,16 @@ from .uiformatters import UIFormatter, get_uiformatters -from ..models_utils.lock_tables import lock_tables +from ..models_utils.lock_tables import mysql_named_lock, autonumbering_lock_table import logging from typing import List, Tuple, Set from collections.abc import Sequence +from django.db import transaction +from django.apps import apps from specifyweb.specify.utils.scoping import Scoping from specifyweb.specify.datamodel import datamodel +from specifyweb.backend.businessrules.models import UniquenessRule, UniquenessRuleField logger = logging.getLogger(__name__) @@ -32,7 +35,9 @@ def autonumber_and_save(collection, user, obj) -> None: obj.save() -def do_autonumbering(collection, obj, fields: list[tuple[UIFormatter, Sequence[str]]]) -> None: +def do_autonumbering_old(collection, obj, fields: list[tuple[UIFormatter, Sequence[str]]]) -> None: + # THis is the old implementation of autonumbering involving lock mysql table explicitly. + # Fall back to using this implementation if race-conditions are found. logger.debug("autonumbering %s fields: %s", obj, fields) # The autonumber action is prepared and thunked outside the locked table @@ -43,12 +48,61 @@ def do_autonumbering(collection, obj, fields: list[tuple[UIFormatter, Sequence[s for formatter, vals in fields ] - with lock_tables(*get_tables_to_lock(collection, obj, [formatter.field_name for formatter, _ in fields])): + # Serialize the autonumbering critical section without table locks. + db_name = transaction.get_connection().alias + table_name = obj._meta.db_table + with autonumbering_lock_table(db_name, table_name): for apply_autonumbering_to in thunks: apply_autonumbering_to(obj) - obj.save() +def do_autonumbering(collection, obj, fields: list[tuple[UIFormatter, Sequence[str]]]) -> None: + logger.debug("autonumbering %s fields: %s", obj, fields) + + # Prepare the thunks/queries (ok to prep outside transaction) + prepared = [] + for formatter, vals in fields: + with_year = formatter.fillin_year(vals, None) + fieldname = formatter.field_name.lower() + prepared.append((formatter, fieldname, with_year)) + + db_name = transaction.get_connection().alias + table_name = obj._meta.db_table + with autonumbering_lock_table(db_name, table_name): + with transaction.atomic(): + for formatter, fieldname, with_year in prepared: + # Build the exact queryset that limits by regex and scope + # Use django's select_for_update() to lock the current max row itself + qs_max = formatter._autonumber_queryset(collection, obj.__class__, fieldname, with_year) + biggest_obj = (qs_max + .select_for_update() + .order_by('-' + fieldname) + .first()) + + if biggest_obj is None: + filled_vals = formatter.fill_vals_no_prior(with_year) + setattr(obj, fieldname, ''.join(filled_vals)) + continue + + # Lock the range of all rows in-scope with field >= biggest_value + biggest_value = getattr(biggest_obj, fieldname) + formatter.lock_ge_range( + collection=collection, + model=obj.__class__, + fieldname=fieldname, + with_year=with_year, + biggest_value=biggest_value, + ) + + # Get next value off the locked biggest and assign + filled_vals = formatter.fill_vals_after(biggest_value) + setattr(obj, fieldname, ''.join(filled_vals)) + + # Save once after all fields are assigned + obj.save() + +def verify_autonumbering(collection, obj, fields): + pass def get_tables_to_lock(collection, obj, field_names) -> set[str]: # TODO: Include the fix for https://github.com/specify/specify7/issues/4148 @@ -77,7 +131,6 @@ def get_tables_to_lock(collection, obj, field_names) -> set[str]: return tables - def get_tables_from_field_path(model: str, field_path: str) -> list[str]: tables = [] table = datamodel.get_table_strict(model) diff --git a/specifyweb/specify/utils/uiformatters.py b/specifyweb/specify/utils/uiformatters.py index 4684e1661fa..7a45e335a74 100644 --- a/specifyweb/specify/utils/uiformatters.py +++ b/specifyweb/specify/utils/uiformatters.py @@ -13,7 +13,7 @@ from xml.sax.saxutils import quoteattr from django.core.exceptions import ObjectDoesNotExist -from django.db import connection +from django.db import connection, transaction logger = logging.getLogger(__name__) @@ -206,6 +206,33 @@ def parser(table: Table, value: str) -> str: return canonicalized return parser + def lock_ge_range( + self, + collection, + model, + fieldname: str, + with_year: list[str], + biggest_value: str, + ) -> None: + assert transaction.get_connection().in_atomic_block, \ + "lock_ge_range() must be used inside transaction.atomic()" + + # Apply the scope logic used by _autonumber_queryset + group_filter = get_autonumber_group_filter(model, collection, self.format_name) + + # Filter by scope, then filter and lock the >= range + base_qs = model.objects.all() + scoped_qs = group_filter(base_qs) + + # Lock rows needed for autonumbering with select_for_update(). + # Avoid deadlocks in select_for_update() with nowait. + # Rely on the sql named lock for avoiding autonumbering race conditions. + ge_filter = {f"{fieldname}__gte": biggest_value} + qs = scoped_qs.filter(**ge_filter).select_for_update(nowait=True) + + # Force evaluation so the locks are actually taken + list(qs.values_list("pk", flat=True)) + class Field(NamedTuple): size: int value: str