DEVX-62: feat: weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
Post-merge / detect-type (push) Successful in 30s
Post-merge / validate-commit-msg (push) Successful in 40s
Post-merge / vikunja (push) Successful in 44s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 58s
Post-merge / sync-wiki (push) Successful in 1m2s
Post-merge / configure-repo (push) Successful in 37s

This commit was merged in pull request #102.
This commit is contained in:
2026-06-26 17:57:03 +00:00
parent e271c79e93
commit 41c631d5f5
8 changed files with 301 additions and 95 deletions
+44
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
_file_weight,
discover_files,
distribute,
files_for_runner,
@@ -169,3 +170,46 @@ def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
class TestFileWeight:
def test_weight_based_on_size(self, tmp_path: Path) -> None:
f = tmp_path / "test_big.py"
f.write_text("x" * 5000)
assert _file_weight(str(f)) == 5000
def test_min_weight_is_1(self, tmp_path: Path) -> None:
f = tmp_path / "empty.py"
f.write_text("")
assert _file_weight(str(f)) == 1
def test_nonexistent_file_returns_1(self) -> None:
assert _file_weight("/nonexistent/file.py") == 1
class TestDistributeLpt:
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
"""Two large files should go to different runners."""
big1 = tmp_path / "test_big1.py"
big2 = tmp_path / "test_big2.py"
small1 = tmp_path / "test_small1.py"
small2 = tmp_path / "test_small2.py"
big1.write_text("x" * 10000)
big2.write_text("x" * 10000)
small1.write_text("x")
small2.write_text("x")
files = [str(big1), str(big2), str(small1), str(small2)]
groups = distribute(files, 2)
runner_0 = groups[0]
runner_1 = groups[1]
# Big files should be on different runners
assert not (str(big1) in runner_0 and str(big2) in runner_0)
assert not (str(big1) in runner_1 and str(big2) in runner_1)
def test_all_files_preserved(self, tmp_path: Path) -> None:
for i in range(5):
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
groups = distribute(files, 3)
flat = sorted(f for group in groups for f in group)
assert flat == sorted(files)
+91
View File
@@ -13,6 +13,8 @@ from devx.molecule.distribute_molecule import (
PLATFORMS,
MultiRoleTestPair,
TestPair,
_lpt_distribute,
_scenario_weight,
build_multi_role_pairs,
build_pairs,
cli,
@@ -477,3 +479,92 @@ class TestCliMultiRole:
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
class TestScenarioWeight:
def test_known_heavy_scenario(self) -> None:
assert _scenario_weight("nextcloud") == 10
assert _scenario_weight("gitea") == 8
def test_known_light_scenario(self) -> None:
assert _scenario_weight("binary") == 2
def test_default_weight(self) -> None:
assert _scenario_weight("unknown-scenario") == 3
def test_case_insensitive(self) -> None:
assert _scenario_weight("NextCloud") == 10
assert _scenario_weight("GITEA") == 8
def test_substring_match(self) -> None:
assert _scenario_weight("nextcloud-with-redis") == 10
assert _scenario_weight("custom-gitea-setup") == 8
class TestLptDistribute:
def test_equal_weights_produce_even_split(self) -> None:
items = list(range(6))
weights = [3, 3, 3, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
assert all(len(g) == 2 for g in groups)
def test_heavy_items_on_different_runners(self) -> None:
"""Two heavy items should go to different runners."""
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
weights = [10, 10, 1, 1]
groups = _lpt_distribute(items, weights, 2)
# Heavy items should be on different runners
flat = [item for group in groups for item in group]
assert "heavy-a" in flat
assert "heavy-b" in flat
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
assert runner_a != runner_b
def test_load_balance_with_varying_weights(self) -> None:
"""LPT should produce better load balance than round-robin."""
items = list(range(7))
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
weights = [10, 10, 7, 7, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
loads = [sum(weights[i] for i in g) for g in groups]
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
assert max(loads) - min(loads) <= 10 # Reasonably balanced
def test_more_runners_than_items(self) -> None:
items = ["a"]
weights = [5]
groups = _lpt_distribute(items, weights, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_items(self) -> None:
groups = _lpt_distribute([], [], 3)
assert groups == [[], [], []]
def test_preserves_all_items(self) -> None:
items = ["a", "b", "c", "d", "e"]
weights = [5, 3, 8, 1, 2]
groups = _lpt_distribute(items, weights, 3)
flat = sorted(item for group in groups for item in group)
assert flat == sorted(items)
class TestDistributeLpt:
def test_nextcloud_on_separate_runners(self) -> None:
"""Two nextcloud scenarios should go to different runners."""
pairs = [
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
TestPair("default", {"name": "p", "image": "i", "command": ""}),
]
groups = distribute(pairs, 2)
# Both nextcloud scenarios (weight 10) should be on different runners
runner_0 = [p.scenario for p in groups[0]]
runner_1 = [p.scenario for p in groups[1]]
# nextcloud and nextcloud-backup should NOT be on the same runner
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)