Shard Rails specs across 8 CI jobs
This commit is contained in:
parent
0bbedd72bd
commit
031f40a85d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
tasks.py
64
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."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue