Shard Rails specs across 8 CI jobs
This commit is contained in:
parent
0bbedd72bd
commit
031f40a85d
|
|
@ -10,6 +10,10 @@ include:
|
||||||
rails spec:
|
rails spec:
|
||||||
stage: test
|
stage: test
|
||||||
image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA
|
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:
|
rules:
|
||||||
- if: $SKIP_TEST
|
- if: $SKIP_TEST
|
||||||
when: never
|
when: never
|
||||||
|
|
@ -19,7 +23,7 @@ rails spec:
|
||||||
script:
|
script:
|
||||||
- cd /app
|
- cd /app
|
||||||
- inv db-migrate
|
- inv db-migrate
|
||||||
- inv test
|
- inv test-shard --node-index=$RSPEC_NODE_INDEX --node-total=$RSPEC_NODE_TOTAL
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- specs_with_runtime.txt
|
- specs_with_runtime.txt
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Examples:
|
||||||
|
|
||||||
- `inv db-migrate`
|
- `inv db-migrate`
|
||||||
- `inv test`
|
- `inv test`
|
||||||
|
- `inv test-shard --node-index=1 --node-total=8`
|
||||||
- `inv lint`
|
- `inv lint`
|
||||||
- `inv verify-http`
|
- `inv verify-http`
|
||||||
- `inv blackbox-production`
|
- `inv blackbox-production`
|
||||||
|
|
@ -22,6 +23,7 @@ Examples:
|
||||||
|
|
||||||
The GitLab CI pipeline is expected to use these tasks as well.
|
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.
|
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
|
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
|
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 subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
SERVER_HOST = "0.0.0.0"
|
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_SECRET_KEY_BASE = "turniere-blackbox-secret-key-base-please-change-in-real-prod"
|
||||||
BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key"
|
BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key"
|
||||||
BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com"
|
BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com"
|
||||||
|
SPEC_ROOT = Path("spec")
|
||||||
|
E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http"
|
||||||
|
|
||||||
|
|
||||||
def _env(**overrides):
|
def _env(**overrides):
|
||||||
|
|
@ -165,6 +168,35 @@ def _print_service_logs(env, service, tail=120):
|
||||||
print(output.rstrip())
|
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():
|
def _shared_production_env():
|
||||||
return _env(
|
return _env(
|
||||||
RAILS_ENV="production",
|
RAILS_ENV="production",
|
||||||
|
|
@ -261,6 +293,38 @@ def test(c):
|
||||||
c.run("bundle exec rspec", pty=True)
|
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
|
@task
|
||||||
def lint(c):
|
def lint(c):
|
||||||
"""Run RuboCop."""
|
"""Run RuboCop."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue