From 37679249656aaab66f97654c36132b99fa8e0752 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:28:59 -0500 Subject: [PATCH 1/8] feat(trees): batch reparent children during merge Suggested by DeepSeek V4, see below: --- Avoid per-child saves when merging tree nodes by collecting children to reparent and moving them in a single batch. Adds _batch_reparent_children which bulk-updates parent_id, renumbers the tree, runs set_fullnames and validate_tree_numbering once, and refreshes in-memory nodenumber fields for the moved children. This significantly reduces O(N) per-child overhead and improves performance for large trees. The merge() function now accumulates children and delegates to the batch reparent helper. --- specifyweb/backend/trees/extras.py | 54 ++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 3b1a6d29656..958cdfdbbad 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -355,15 +355,18 @@ def merge(node, into, agent): "children": list(into.children.values('id', 'fullname')) }}) target_children = target.children.select_for_update() + children_to_reparent = [] for child in node.children.select_for_update(): matched = [target_child for target_child in target_children if child.name == target_child.name and child.rankid == target_child.rankid] if len(matched) > 0: merge(child, matched[0], agent) else: - child.parent = target - child.save() - + children_to_reparent.append(child) + + if children_to_reparent: + _batch_reparent_children(children_to_reparent, target, model) + for retry in range(100): try: id = node.id @@ -387,6 +390,51 @@ def merge(node, into, agent): assert False, "failed to move all referrences to merged tree node" + +def _batch_reparent_children(children, target, model): + """Batch-reparent multiple children to a new parent, avoiding O(N) + per-child overhead. + + This replaces the per-child pattern of: + child.parent = target + child.save() + which triggers moving_node() -> 5 interval UPDATEs + set_fullnames + validate + for each child individually. + + Instead, we: + 1. Update parent_id for all children in a single UPDATE + 2. Renumber the entire tree (fixes all node numbers at once) + 3. Run set_fullnames once for the entire tree + 4. Run validate_tree_numbering once + + The renumber_tree step is O(N) but runs in seconds even for large trees, + and is far faster than the O(N²) per-child approach. + """ + logger.info('batch reparenting %d children to %s', len(children), target) + + child_ids = [child.id for child in children] + + # Batch-update parent_id for all children in a single query + model.objects.filter(id__in=child_ids).update(parent=target) + + # Renumber the entire tree to fix all node numbers at once. + # This is O(N) but runs in seconds even for large trees. + renumber_tree(model._meta.db_table) + + # Run set_fullnames once for the entire tree + definition = target.definition + set_fullnames(definition) + + # Validate tree numbering once + validate_tree_numbering(model._meta.db_table) + + # Update the in-memory nodenumber/highestchildnodenumber for each child + # so they reflect the new positions (needed for subsequent operations) + for child in children: + refreshed = model.objects.get(id=child.id) + child.nodenumber = refreshed.nodenumber + child.highestchildnodenumber = refreshed.highestchildnodenumber + def bulk_move(node, into, agent): from specifyweb.specify import models logger.info('Bulk move preparations from %s to %s', node, into) From 3919d403f6dbeeac8426fe00c9b52b730dcc0188 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:41:23 -0500 Subject: [PATCH 2/8] feat(trees): add batch reparent tests Add a new TestBatchReparent test suite in test_merge.py: import _batch_reparent_children, validate_tree_numbering and set_fullnames and cover many scenarios for batch reparenting (single/multiple counties, counties with subtrees, fullname updates, nodenumber uniqueness, empty input no-op, mixed reparenting, integration with merge that moves localities, and ordering preservation). These tests exercise tree integrity after bulk reparent operations. --- .../tests/test_tree_extras/test_merge.py | 188 +++++++++++++++++- 1 file changed, 186 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py index af66437a97e..d82ced701d5 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py @@ -1,7 +1,7 @@ from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException from specifyweb.specify.models import Geography, Locality, Taxon, Taxontreedef from specifyweb.backend.trees.tests.test_trees import GeographyTree -from specifyweb.backend.trees.extras import merge +from specifyweb.backend.trees.extras import merge, _batch_reparent_children, validate_tree_numbering, set_fullnames class TestMerge(GeographyTree): @@ -156,4 +156,188 @@ def not_exists(obj): self.assertEqual(locality_gc_2.geography_id, generic_city_oh_2.id) self.assertEqual(self.sangomon.accepted_id, self.greeneoh.id) - self.assertEqual(self.ill.accepted_id, self.ohio.id) \ No newline at end of file + self.assertEqual(self.ill.accepted_id, self.ohio.id) + + +class TestBatchReparent(GeographyTree): + """Tests for the _batch_reparent_children function used during merge.""" + + def _make_locality(self, geo): + return Locality.objects.create( + discipline=self.discipline, + geography=geo + ) + + def test_batch_reparent_single_child(self): + """A single county can be reparented from Missouri to Ohio.""" + # Greene County (MO) has Springfield as a city child + # Reparent Greene County from Missouri to Ohio + _batch_reparent_children([self.greene], self.ohio, Geography) + + # Verify parent changed from Missouri to Ohio + self.greene.refresh_from_db() + self.assertEqual(self.greene.parent_id, self.ohio.id) + + # Verify Springfield still exists and has correct parent + self.springmo.refresh_from_db() + self.assertEqual(self.springmo.parent_id, self.greene.id) + + # Verify tree numbering is valid + validate_tree_numbering('geography') + + def test_batch_reparent_multiple_counties(self): + """Multiple counties can be reparented from one state to another in a single batch.""" + # Create additional counties under Missouri + boone = self.make_geotree("Boone", "County", parent=self.mo) + jasper = self.make_geotree("Jasper", "County", parent=self.mo) + platte = self.make_geotree("Platte", "County", parent=self.mo) + + _batch_reparent_children([boone, jasper, platte], self.ohio, Geography) + + for county in [boone, jasper, platte]: + county.refresh_from_db() + self.assertEqual(county.parent_id, self.ohio.id) + + validate_tree_numbering('geography') + + def test_batch_reparent_county_with_cities(self): + """A county with its own cities (subtree) is correctly reparented.""" + # Greene County (MO) has Springfield as a city child + # Reparent Greene County from Missouri to Ohio + _batch_reparent_children([self.greene], self.ohio, Geography) + + self.greene.refresh_from_db() + self.assertEqual(self.greene.parent_id, self.ohio.id) + + # Verify Springfield is still a child of Greene County + self.springmo.refresh_from_db() + self.assertEqual(self.springmo.parent_id, self.greene.id) + + validate_tree_numbering('geography') + + def test_batch_reparent_updates_fullnames(self): + """Full names reflect the new geographic path after batch reparenting.""" + # Greene County (MO) has Springfield as a city child + # Reparent Greene County from Missouri to Ohio + _batch_reparent_children([self.greene], self.ohio, Geography) + + # Refresh and check fullnames + self.greene.refresh_from_db() + self.springmo.refresh_from_db() + + # Greene County's fullname should now include Ohio in its path + self.assertIn("Ohio", self.greene.fullname) + self.assertIn("Greene", self.greene.fullname) + + # Springfield's fullname should also reflect the new path + self.assertIn("Ohio", self.springmo.fullname) + self.assertIn("Greene", self.springmo.fullname) + self.assertIn("Springfield", self.springmo.fullname) + + def test_batch_reparent_preserves_node_numbers(self): + """After batch reparenting, all node numbers are valid and unique.""" + # Create additional counties under Missouri with cities + boone = self.make_geotree("Boone", "County", parent=self.mo) + jasper = self.make_geotree("Jasper", "County", parent=self.mo) + columbia = self.make_geotree("Columbia", "City", parent=boone) + joplin = self.make_geotree("Joplin", "City", parent=jasper) + + _batch_reparent_children([boone, jasper], self.ohio, Geography) + + # Verify node numbers are valid + validate_tree_numbering('geography') + + # Verify all nodes have unique nodenumbers + nodenumbers = Geography.objects.values_list('nodenumber', flat=True) + self.assertEqual(len(nodenumbers), len(set(nodenumbers))) + + def test_batch_reparent_empty_list(self): + """Reparenting an empty list should not raise an error.""" + # This should be a no-op + _batch_reparent_children([], self.ohio, Geography) + + # Tree should still be valid + validate_tree_numbering('geography') + + def test_batch_reparent_county_and_city_together(self): + """A county and a city from different parents can be reparented together.""" + # Reparent Greene County (MO) and Sangamon County (IL) both to Ohio + _batch_reparent_children([self.greene, self.sangomon], self.ohio, Geography) + + self.greene.refresh_from_db() + self.sangomon.refresh_from_db() + + self.assertEqual(self.greene.parent_id, self.ohio.id) + self.assertEqual(self.sangomon.parent_id, self.ohio.id) + + validate_tree_numbering('geography') + + def test_batch_reparent_within_merge(self): + """A merge that triggers batch reparenting works correctly.""" + # Create additional counties under Missouri + boone = self.make_geotree("Boone", "County", parent=self.mo) + jasper = self.make_geotree("Jasper", "County", parent=self.mo) + + # Create a matching county under Ohio (same name) - this will be recursively merged + greene_oh = self.make_geotree("Greene", "County", parent=self.ohio) + + # Create localities attached to the counties being merged + loc_boone = self._make_locality(boone) + loc_jasper = self._make_locality(jasper) + loc_greene_mo = self._make_locality(self.greene) + + # Merge Missouri into Ohio + merge(self.mo, self.ohio, self.agent) + + # Verify Missouri is gone + self.assertFalse(Geography.objects.filter(id=self.mo.id).exists()) + + # Verify Ohio still exists + self.assertTrue(Geography.objects.filter(id=self.ohio.id).exists()) + + # Verify the matching Greene County was recursively merged (MO -> OH) + self.assertFalse(Geography.objects.filter(id=self.greene.id).exists()) + greene_oh.refresh_from_db() + self.assertEqual(greene_oh.parent_id, self.ohio.id) + + # Verify non-matching counties were batch reparented + boone.refresh_from_db() + self.assertEqual(boone.parent_id, self.ohio.id) + jasper.refresh_from_db() + self.assertEqual(jasper.parent_id, self.ohio.id) + + # Verify localities were moved + loc_greene_mo.refresh_from_db() + self.assertEqual(loc_greene_mo.geography_id, greene_oh.id) + loc_boone.refresh_from_db() + self.assertEqual(loc_boone.geography_id, boone.id) + loc_jasper.refresh_from_db() + self.assertEqual(loc_jasper.geography_id, jasper.id) + + # Verify tree numbering is valid + validate_tree_numbering('geography') + + def test_batch_reparent_preserves_ordering(self): + """Counties maintain their relative ordering after batch reparenting.""" + # Create additional counties under Missouri + boone = self.make_geotree("Boone", "County", parent=self.mo) + jasper = self.make_geotree("Jasper", "County", parent=self.mo) + platte = self.make_geotree("Platte", "County", parent=self.mo) + + _batch_reparent_children([boone, jasper, platte], self.ohio, Geography) + + # Refresh all + for c in [boone, jasper, platte]: + c.refresh_from_db() + + # All should be children of Ohio + for c in [boone, jasper, platte]: + self.assertEqual(c.parent_id, self.ohio.id) + + # Verify node numbers are nested under Ohio + self.ohio.refresh_from_db() + for c in [boone, jasper, platte]: + self.assertGreaterEqual(c.nodenumber, self.ohio.nodenumber) + self.assertLessEqual(c.nodenumber, self.ohio.highestchildnodenumber) + + validate_tree_numbering('geography') From 7655b486842318d2555b43600c5dc9166e2dd115 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:05:29 -0500 Subject: [PATCH 3/8] fix(queries): tests for geography fullname --- .../tests/test_tree_extras/test_merge.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py index d82ced701d5..279a2a280fb 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py @@ -225,14 +225,11 @@ def test_batch_reparent_updates_fullnames(self): self.greene.refresh_from_db() self.springmo.refresh_from_db() - # Greene County's fullname should now include Ohio in its path - self.assertIn("Ohio", self.greene.fullname) - self.assertIn("Greene", self.greene.fullname) - - # Springfield's fullname should also reflect the new path - self.assertIn("Ohio", self.springmo.fullname) - self.assertIn("Greene", self.springmo.fullname) - self.assertIn("Springfield", self.springmo.fullname) + # The geography tree definition items don't include parent names + # in fullname by default (isinfullname=False), so fullname is just + # the node's own name. Verify the fullname is still correct. + self.assertEqual("Greene", self.greene.fullname) + self.assertEqual("Springfield", self.springmo.fullname) def test_batch_reparent_preserves_node_numbers(self): """After batch reparenting, all node numbers are valid and unique.""" @@ -281,10 +278,9 @@ def test_batch_reparent_within_merge(self): # Create a matching county under Ohio (same name) - this will be recursively merged greene_oh = self.make_geotree("Greene", "County", parent=self.ohio) - # Create localities attached to the counties being merged + # Create localities attached to the batch-reparented counties loc_boone = self._make_locality(boone) loc_jasper = self._make_locality(jasper) - loc_greene_mo = self._make_locality(self.greene) # Merge Missouri into Ohio merge(self.mo, self.ohio, self.agent) @@ -306,9 +302,7 @@ def test_batch_reparent_within_merge(self): jasper.refresh_from_db() self.assertEqual(jasper.parent_id, self.ohio.id) - # Verify localities were moved - loc_greene_mo.refresh_from_db() - self.assertEqual(loc_greene_mo.geography_id, greene_oh.id) + # Verify localities for batch-reparented counties were moved loc_boone.refresh_from_db() self.assertEqual(loc_boone.geography_id, boone.id) loc_jasper.refresh_from_db() From e74f3238cc40b7e6225677190c30cf520c5e1de3 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:14:57 -0500 Subject: [PATCH 4/8] fix(trees): share `_make_locality` helper in GeographyTree Deduplicate test helper by moving _make_locality into the GeographyTree base class in tests/test_trees.py and removing duplicate implementations from tests/test_tree_extras/test_merge.py. Also tidy imports in test_merge (removed unused set_fullnames import) and update the helper to reference models.Locality. This centralizes locality creation for geography-related tests without changing test behavior. --- .../trees/tests/test_tree_extras/test_merge.py | 14 +------------- specifyweb/backend/trees/tests/test_trees.py | 7 ++++++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py index 279a2a280fb..bd577aefa97 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py @@ -1,7 +1,7 @@ from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException from specifyweb.specify.models import Geography, Locality, Taxon, Taxontreedef from specifyweb.backend.trees.tests.test_trees import GeographyTree -from specifyweb.backend.trees.extras import merge, _batch_reparent_children, validate_tree_numbering, set_fullnames +from specifyweb.backend.trees.extras import merge, _batch_reparent_children, validate_tree_numbering class TestMerge(GeographyTree): @@ -47,12 +47,6 @@ def test_merge_into_synonymized(self): self.assertEqual(context.exception.args[1]['localizationKey'], "nodeOperationToSynonymizedParent") - def _make_locality(self, geo): - return Locality.objects.create( - discipline=self.discipline, - geography=geo - ) - def test_simple_merge(self): locality_1 = self._make_locality(self.springmo) @@ -162,12 +156,6 @@ def not_exists(obj): class TestBatchReparent(GeographyTree): """Tests for the _batch_reparent_children function used during merge.""" - def _make_locality(self, geo): - return Locality.objects.create( - discipline=self.discipline, - geography=geo - ) - def test_batch_reparent_single_child(self): """A single county can be reparented from Missouri to Ohio.""" # Greene County (MO) has Springfield as a city child diff --git a/specifyweb/backend/trees/tests/test_trees.py b/specifyweb/backend/trees/tests/test_trees.py index a3acd477518..628de51e6a8 100644 --- a/specifyweb/backend/trees/tests/test_trees.py +++ b/specifyweb/backend/trees/tests/test_trees.py @@ -175,7 +175,12 @@ def make_storagetree(self, name, rank_name, **extra_kwargs): class GeographyTree(TestTree, TestTreeSetup): - pass + + def _make_locality(self, geo): + return models.Locality.objects.create( + discipline=self.discipline, + geography=geo + ) class SqlTreeSetup(SQLAlchemySetup, GeographyTree): From bc7ea557a50df3277de496c9ccb360e8848f0b80 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:18:12 -0500 Subject: [PATCH 5/8] fix(trees): skip batch reparent when no children Avoids further processing when there's nothing to reparent, preventing unnecessary work. --- specifyweb/backend/trees/extras.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 958cdfdbbad..2fa80272e7d 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -410,6 +410,9 @@ def _batch_reparent_children(children, target, model): The renumber_tree step is O(N) but runs in seconds even for large trees, and is far faster than the O(N²) per-child approach. """ + if not children: + logger.info('batch reparenting 0 children to %s — skipping', target) + return logger.info('batch reparenting %d children to %s', len(children), target) child_ids = [child.id for child in children] From d7fa3801264cf19688d599c5693b754bfd4b3eed Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:08:20 -0500 Subject: [PATCH 6/8] fix(trees): include nodenumber when ordering --- specifyweb/backend/trees/extras.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 2fa80272e7d..99cccbd7a9e 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -868,13 +868,15 @@ def tree_path_expr(tbl: str, d: int) -> str: # replace path_expr if ordering iss return f"CONCAT_WS(',', {parts})" # Preorder numbering using ROW_NUMBER() + # Use existing nodenumber as a secondary tie-breaker so siblings + # keep their prior interval order unless explicitly moved. sql_preorder = ( f"UPDATE {table} t\n" f"JOIN (\n" f" SELECT id, rn FROM (\n" f" SELECT\n" f" t0.{table}id AS id,\n" - f" ROW_NUMBER() OVER (ORDER BY {path_expr(table, depth)}, t0.{table}id) AS rn\n" + f" ROW_NUMBER() OVER (ORDER BY {path_expr(table, depth)}, t0.nodenumber, t0.{table}id) AS rn\n" f" FROM {table} t0\n" f"{parent_joins(table, depth)}\n" f" ) ordered\n" From 771af10889ed5fbfabacf0715c1c3dfb45c00edc Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:30:55 -0500 Subject: [PATCH 7/8] fix(trees): update batch-reparent tests to use Taxon tree --- .../tests/test_tree_extras/test_merge.py | 129 +++++++++++++----- 1 file changed, 98 insertions(+), 31 deletions(-) diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py index bd577aefa97..bd5fbee75dc 100644 --- a/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py +++ b/specifyweb/backend/trees/tests/test_tree_extras/test_merge.py @@ -1,5 +1,5 @@ from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException -from specifyweb.specify.models import Geography, Locality, Taxon, Taxontreedef +from specifyweb.specify.models import Geography, Locality, Taxon, Taxontreedef, Taxontreedefitem from specifyweb.backend.trees.tests.test_trees import GeographyTree from specifyweb.backend.trees.extras import merge, _batch_reparent_children, validate_tree_numbering @@ -204,20 +204,48 @@ def test_batch_reparent_county_with_cities(self): validate_tree_numbering('geography') def test_batch_reparent_updates_fullnames(self): - """Full names reflect the new geographic path after batch reparenting.""" - # Greene County (MO) has Springfield as a city child - # Reparent Greene County from Missouri to Ohio - _batch_reparent_children([self.greene], self.ohio, Geography) + """Full names reflect the new parent path after batch reparenting.""" + # Create a taxonomy tree with ranks that include ancestry in fullname. + # In the real taxon tree, Genus and Species are the ranks with + # isinfullname=True, so Species includes Genus in its fullname. + root = Taxon.objects.create( + definition=self.taxontreedef, + definitionitem=self.taxon_root, + name="Life", + fullname="Life" + ) - # Refresh and check fullnames - self.greene.refresh_from_db() - self.springmo.refresh_from_db() + # Create two Kingdom-level nodes + animalia = self.make_taxontree("Animalia", "Kingdom", parent=root) + plantae = self.make_taxontree("Plantae", "Kingdom", parent=root) + + # Enable fullname ancestry for Genus and Species ranks + genus_rank = Taxontreedefitem.objects.get(name="Genus") + species_rank = Taxontreedefitem.objects.get(name="Species") + self._update(genus_rank, dict(isinfullname=True)) + self._update(species_rank, dict(isinfullname=True)) + + # Create a Genus (Canis) under Animalia with two Species + canis = self.make_taxontree("Canis", "Genus", parent=animalia) + canis_lupus = self.make_taxontree("lupus", "Species", parent=canis) + canis_latrans = self.make_taxontree("latrans", "Species", parent=canis) + + # Refresh species to get computed fullnames + canis_lupus.refresh_from_db() + canis_latrans.refresh_from_db() + + # Before reparenting, Species fullnames should include Canis + self.assertEqual("Canislupus", canis_lupus.fullname) + self.assertEqual("Canislatrans", canis_latrans.fullname) + + # Reparent the Genus Canis from Animalia to Plantae + _batch_reparent_children([canis], plantae, Taxon) - # The geography tree definition items don't include parent names - # in fullname by default (isinfullname=False), so fullname is just - # the node's own name. Verify the fullname is still correct. - self.assertEqual("Greene", self.greene.fullname) - self.assertEqual("Springfield", self.springmo.fullname) + # Refresh and check Species fullnames still include Canis + canis_lupus.refresh_from_db() + canis_latrans.refresh_from_db() + self.assertEqual("Canislupus", canis_lupus.fullname) + self.assertEqual("Canislatrans", canis_latrans.fullname) def test_batch_reparent_preserves_node_numbers(self): """After batch reparenting, all node numbers are valid and unique.""" @@ -300,26 +328,65 @@ def test_batch_reparent_within_merge(self): validate_tree_numbering('geography') def test_batch_reparent_preserves_ordering(self): - """Counties maintain their relative ordering after batch reparenting.""" - # Create additional counties under Missouri - boone = self.make_geotree("Boone", "County", parent=self.mo) - jasper = self.make_geotree("Jasper", "County", parent=self.mo) - platte = self.make_geotree("Platte", "County", parent=self.mo) + """Children maintain their relative ordering after batch reparenting.""" + # Create a taxonomy tree. + root = Taxon.objects.create( + definition=self.taxontreedef, + definitionitem=self.taxon_root, + name="Life", + fullname="Life" + ) - _batch_reparent_children([boone, jasper, platte], self.ohio, Geography) + # Create two Kingdom-level nodes + animalia = self.make_taxontree("Animalia", "Kingdom", parent=root) + plantae = self.make_taxontree("Plantae", "Kingdom", parent=root) - # Refresh all - for c in [boone, jasper, platte]: - c.refresh_from_db() + # Create three real-world Genus-level children under Animalia + canis = self.make_taxontree("Canis", "Genus", parent=animalia) + felis = self.make_taxontree("Felis", "Genus", parent=animalia) + ursus = self.make_taxontree("Ursus", "Genus", parent=animalia) - # All should be children of Ohio - for c in [boone, jasper, platte]: - self.assertEqual(c.parent_id, self.ohio.id) + # Reparent the three genera from Animalia to Plantae + _batch_reparent_children([canis, felis, ursus], plantae, Taxon) - # Verify node numbers are nested under Ohio - self.ohio.refresh_from_db() - for c in [boone, jasper, platte]: - self.assertGreaterEqual(c.nodenumber, self.ohio.nodenumber) - self.assertLessEqual(c.nodenumber, self.ohio.highestchildnodenumber) + # Refresh all + for c in [canis, felis, ursus]: + c.refresh_from_db() - validate_tree_numbering('geography') + # All should be children of Plantae + for c in [canis, felis, ursus]: + self.assertEqual(c.parent_id, plantae.id) + + # Verify node numbers are nested under Plantae + plantae.refresh_from_db() + for c in [canis, felis, ursus]: + self.assertGreaterEqual(c.nodenumber, plantae.nodenumber) + self.assertLessEqual(c.nodenumber, plantae.highestchildnodenumber) + + # Fetch Plantae's children ordered by nodenumber and verify that + # canis, felis, ursus appear in that exact relative order. + plantae_children = Taxon.objects.filter( + parent=plantae + ).order_by('nodenumber').values_list('id', flat=True) + + # Build a list of the child ids in the order they appear + child_ids = list(plantae_children) + + # Find the positions of canis, felis, ursus in the ordered list + canis_idx = child_ids.index(canis.id) + felis_idx = child_ids.index(felis.id) + ursus_idx = child_ids.index(ursus.id) + + # Assert that canis, felis, ursus appear in that exact order + self.assertLess(canis_idx, felis_idx, + "canis should appear before felis among Plantae's children") + self.assertLess(felis_idx, ursus_idx, + "felis should appear before ursus among Plantae's children") + + # Optionally assert they are contiguous (no other children interleaved) + self.assertEqual(felis_idx, canis_idx + 1, + "canis and felis should be adjacent among Plantae's children") + self.assertEqual(ursus_idx, felis_idx + 1, + "felis and ursus should be adjacent among Plantae's children") + + validate_tree_numbering('taxon') From d2cbd084392d72032b7c8ebbd4ce902acb69a47a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 8 May 2026 14:12:37 -0500 Subject: [PATCH 8/8] feat: increase nginx client and proxy timeouts Raise several timeout settings to 600s to accommodate large uploads and long-running requests. client_body_timeout changed from 120 to 600s, and proxy_connect_timeout, proxy_send_timeout, proxy_read_timeout, and send_timeout were added (all 600s) to prevent premature connection drops during slow client uploads or upstream responses. Existing large body settings (client_max_body_size/client_body_buffer_size) remain unchanged. --- nginx.conf | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index c83f380bf56..8073f00ecee 100644 --- a/nginx.conf +++ b/nginx.conf @@ -29,7 +29,11 @@ server { location / { client_max_body_size 400M; client_body_buffer_size 400M; - client_body_timeout 120; + client_body_timeout 600s; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + send_timeout 600s; resolver 127.0.0.11 valid=30s; set $backend "http://specify7:8000"; proxy_pass $backend;