From 031f40a85d2231abb0c81a4382a3d709d802f05c Mon Sep 17 00:00:00 2001 From: Malaber Date: Sat, 11 Apr 2026 15:40:56 +0200 Subject: [PATCH] Shard Rails specs across 8 CI jobs --- .gitlab-ci.yml | 6 ++++- AGENTS.md | 2 ++ tasks.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e92881..ff0f65e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,10 @@ include: rails spec: stage: test image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA + parallel: + matrix: + - RSPEC_NODE_INDEX: ["1", "2", "3", "4", "5", "6", "7", "8"] + RSPEC_NODE_TOTAL: "8" rules: - if: $SKIP_TEST when: never @@ -19,7 +23,7 @@ rails spec: script: - cd /app - inv db-migrate - - inv test + - inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL artifacts: paths: - specs_with_runtime.txt diff --git a/AGENTS.md b/AGENTS.md index 146eb18..f4d9a9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Examples: - `inv db-migrate` - `inv test` +- `inv test-shard --node-index=1 --node-total=8` - `inv lint` - `inv verify-http` - `inv blackbox-production` @@ -22,6 +23,7 @@ Examples: The GitLab CI pipeline is expected to use these tasks as well. In CI, blackbox E2E should target sidecar service containers rather than Docker Compose inside the job. +The normal Rails spec suite is sharded through `inv test-shard ...` and should stay aligned with the CI matrix configuration. This is not just a convenience preference. The task layer is the operational contract for this repo and should stay aligned across local use, CI, and diff --git a/tasks.py b/tasks.py index 8b6641b..1d61fed 100644 --- a/tasks.py +++ b/tasks.py @@ -7,6 +7,7 @@ import signal import subprocess import time import urllib.request +from pathlib import Path SERVER_HOST = "0.0.0.0" @@ -31,6 +32,8 @@ BLACKBOX_DB_PASSWORD = "turniere" BLACKBOX_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod" BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key" BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" +SPEC_ROOT = Path("spec") +E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" def _env(**overrides): @@ -165,6 +168,35 @@ def _print_service_logs(env, service, tail=120): print(output.rstrip()) +def _spec_files(): + return sorted( + path for path in SPEC_ROOT.rglob("*_spec.rb") + if not path.is_relative_to(E2E_SPEC_ROOT) + ) + + +def _spec_weight(path): + return sum(1 for _ in path.open("r", encoding="utf-8")) + + +def _spec_shards(node_total): + if node_total <= 0: + raise ValueError("node_total must be positive") + + shards = [{"weight": 0, "files": []} for _ in range(node_total)] + weighted_files = sorted( + ((path, _spec_weight(path)) for path in _spec_files()), + key=lambda item: (-item[1], str(item[0])), + ) + + for path, weight in weighted_files: + shard = min(shards, key=lambda item: (item["weight"], len(item["files"]))) + shard["files"].append(path) + shard["weight"] += weight + + return shards + + def _shared_production_env(): return _env( RAILS_ENV="production", @@ -261,6 +293,38 @@ def test(c): c.run("bundle exec rspec", pty=True) +@task(name="test-shard") +def test_shard(c, node_index=1, node_total=8, dry_run=False): + """Run one deterministic shard of the non-E2E RSpec suite.""" + node_index = int(node_index) + node_total = int(node_total) + + if node_index < 1 or node_index > node_total: + raise ValueError(f"node_index must be between 1 and {node_total}, got {node_index}") + + shards = _spec_shards(node_total) + shard = shards[node_index - 1] + files = [str(path) for path in shard["files"]] + + _print_header(f"RSpec shard {node_index}/{node_total}") + print(f"Files: {len(files)}") + print(f"Estimated weight: {shard['weight']}") + if files: + print("Included specs:") + for path in files: + print(f"- {path}") + else: + print("No spec files assigned to this shard.") + + if dry_run: + return + + if not files: + return + + c.run("bundle exec rspec " + " ".join(shlex.quote(path) for path in files), pty=True) + + @task def lint(c): """Run RuboCop."""