@@ -442,3 +442,189 @@ def test_filters_combined_with_sync_attrs():
442442 diffs = de .get_attrs_diffs ()
443443 if "+" in diffs :
444444 assert "tag" not in diffs ["+" ]
445+
446+
447+ # ---------------------------------------------------------------------------
448+ # sync_stages — ordered group execution for concurrent sync
449+ # ---------------------------------------------------------------------------
450+
451+
452+ # Models and adapters for sync_stages tests — uses multiple top-level types
453+ # to exercise staged parallelism.
454+
455+ _creation_order : List = []
456+
457+
458+ class _Region (DiffSyncModel ):
459+ _modelname = "region"
460+ _identifiers = ("name" ,)
461+ _attributes = ("slug" ,)
462+
463+ name : str
464+ slug : str = ""
465+
466+ @classmethod
467+ def create (cls , adapter , ids , attrs ):
468+ _creation_order .append (("region" , ids ["name" ]))
469+ return super ().create (adapter = adapter , ids = ids , attrs = attrs )
470+
471+
472+ class _Tenant (DiffSyncModel ):
473+ _modelname = "tenant"
474+ _identifiers = ("name" ,)
475+ _attributes = ("group" ,)
476+
477+ name : str
478+ group : str = ""
479+
480+ @classmethod
481+ def create (cls , adapter , ids , attrs ):
482+ _creation_order .append (("tenant" , ids ["name" ]))
483+ return super ().create (adapter = adapter , ids = ids , attrs = attrs )
484+
485+
486+ class _Rack (DiffSyncModel ):
487+ _modelname = "rack"
488+ _identifiers = ("name" ,)
489+ _attributes = ("site_name" ,)
490+
491+ name : str
492+ site_name : str = ""
493+
494+ @classmethod
495+ def create (cls , adapter , ids , attrs ):
496+ _creation_order .append (("rack" , ids ["name" ]))
497+ return super ().create (adapter = adapter , ids = ids , attrs = attrs )
498+
499+
500+ class _StagedAdapter (Adapter ):
501+ region = _Region
502+ tenant = _Tenant
503+ rack = _Rack
504+ top_level = ["region" , "tenant" , "rack" ]
505+ sync_stages = [
506+ ["region" , "tenant" ], # stage 1: independent, can run in parallel
507+ ["rack" ], # stage 2: depends on regions being created
508+ ]
509+
510+
511+ class _UnstagedAdapter (Adapter ):
512+ """Same models, no sync_stages — for comparison."""
513+ region = _Region
514+ tenant = _Tenant
515+ rack = _Rack
516+ top_level = ["region" , "tenant" , "rack" ]
517+
518+
519+ def _make_staged_pair (adapter_cls = _StagedAdapter ):
520+ """Build a source with regions/tenants/racks and an empty destination."""
521+ src = adapter_cls ()
522+ dst = adapter_cls ()
523+
524+ src .add (_Region (name = "region1" , slug = "r1" ))
525+ src .add (_Region (name = "region2" , slug = "r2" ))
526+ src .add (_Tenant (name = "tenant1" , group = "g1" ))
527+ src .add (_Rack (name = "rack1" , site_name = "region1" ))
528+ src .add (_Rack (name = "rack2" , site_name = "region2" ))
529+
530+ return src , dst
531+
532+
533+ def test_sync_stages_executes_in_order ():
534+ """All stage-1 types (region, tenant) must be created before any stage-2 type (rack)."""
535+ _creation_order .clear ()
536+ src , dst = _make_staged_pair ()
537+ dst .sync_from (src , concurrent = True , max_workers = 4 )
538+
539+ # Find the index of the first rack creation
540+ rack_indices = [i for i , (t , _ ) in enumerate (_creation_order ) if t == "rack" ]
541+ region_indices = [i for i , (t , _ ) in enumerate (_creation_order ) if t == "region" ]
542+ tenant_indices = [i for i , (t , _ ) in enumerate (_creation_order ) if t == "tenant" ]
543+
544+ assert len (rack_indices ) == 2
545+ assert len (region_indices ) == 2
546+ assert len (tenant_indices ) == 1
547+
548+ # All stage-1 creations (regions + tenants) must come before any stage-2 creation (racks)
549+ max_stage1_index = max (max (region_indices ), max (tenant_indices ))
550+ min_stage2_index = min (rack_indices )
551+ assert max_stage1_index < min_stage2_index , (
552+ f"Stage 1 items must all complete before stage 2 begins. "
553+ f"Order was: { _creation_order } "
554+ )
555+
556+
557+ def test_sync_stages_parallelizes_within_stage ():
558+ """Two independent top-level types in the same stage should both be processed."""
559+ _creation_order .clear ()
560+ src , dst = _make_staged_pair ()
561+ dst .sync_from (src , concurrent = True , max_workers = 4 )
562+
563+ types_created = {t for t , _ in _creation_order }
564+ assert "region" in types_created
565+ assert "tenant" in types_created
566+ assert "rack" in types_created
567+
568+
569+ def test_sync_stages_none_preserves_current_behavior ():
570+ """sync_stages=None with concurrent=True should behave like the original unstaged concurrent sync."""
571+ _creation_order .clear ()
572+ src , dst = _make_staged_pair (_UnstagedAdapter )
573+ dst .sync_from (src , concurrent = True , max_workers = 2 )
574+
575+ assert dst .get_or_none ("region" , "region1" ) is not None
576+ assert dst .get_or_none ("tenant" , "tenant1" ) is not None
577+ assert dst .get_or_none ("rack" , "rack1" ) is not None
578+
579+
580+ def test_sync_stages_ignored_when_serial ():
581+ """sync_stages should have no effect on serial sync — top_level order is used."""
582+ _creation_order .clear ()
583+ src , dst = _make_staged_pair ()
584+ dst .sync_from (src , concurrent = False )
585+
586+ assert dst .get_or_none ("region" , "region1" ) is not None
587+ assert dst .get_or_none ("rack" , "rack1" ) is not None
588+
589+
590+ def test_sync_stages_validation_rejects_unknown_type ():
591+ """A type in sync_stages that is not in top_level should raise AttributeError."""
592+ import pytest
593+
594+ with pytest .raises (AttributeError , match = "sync_stages.*not in top_level" ):
595+ class _BadAdapter (Adapter ):
596+ region = _Region
597+ top_level = ["region" ]
598+ sync_stages = [["region" , "nonexistent" ]]
599+
600+
601+ def test_sync_stages_validation_rejects_duplicates ():
602+ """A type appearing in multiple stages should raise AttributeError."""
603+ import pytest
604+
605+ with pytest .raises (AttributeError , match = "sync_stages.*duplicate" ):
606+ class _BadAdapter (Adapter ):
607+ region = _Region
608+ tenant = _Tenant
609+ top_level = ["region" , "tenant" ]
610+ sync_stages = [["region" , "tenant" ], ["region" ]]
611+
612+
613+ def test_sync_stages_unstaged_types_still_sync ():
614+ """A type in top_level but not in any stage should still be synced (serially, after all stages)."""
615+
616+ class _PartialStagesAdapter (Adapter ):
617+ region = _Region
618+ tenant = _Tenant
619+ rack = _Rack
620+ top_level = ["region" , "tenant" , "rack" ]
621+ sync_stages = [["region" ]] # tenant and rack not staged
622+
623+ _creation_order .clear ()
624+ src , dst = _make_staged_pair (_PartialStagesAdapter )
625+ dst .sync_from (src , concurrent = True , max_workers = 2 )
626+
627+ # All types should still be synced
628+ assert dst .get_or_none ("region" , "region1" ) is not None
629+ assert dst .get_or_none ("tenant" , "tenant1" ) is not None
630+ assert dst .get_or_none ("rack" , "rack1" ) is not None
0 commit comments