From 22d122934e1a3104299ee425ad39def058d5936f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 05:50:53 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Test:=20=E5=85=A8=E4=BD=93=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E4=B8=A6=E5=88=97=E3=81=A7=E5=9B=9E?= =?UTF-8?q?=E3=81=97=E3=80=81=E9=87=8D=E8=A4=87=E3=81=97=E3=81=9F=E7=AB=B6?= =?UTF-8?q?=E5=90=88=E8=A9=A6=E9=A8=93=E3=81=A8=E7=B9=B0=E3=82=8A=E8=BF=94?= =?UTF-8?q?=E3=81=97=E3=82=92=E6=B8=9B=E3=82=89=E3=81=99=EF=BC=88#882=20#8?= =?UTF-8?q?84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 継続的統合の全体テストを -n auto で回す。release/** の push 契機を外し、同じテストの 2 重実行をやめる - 全体テストを案内するコマンドを並列の形にする(CONTRIBUTING.md・PR テンプレート・開発ガイド・確定仕様 2 件) - 臨界区間の競合試験を共通実装への 1 通り(並列 12・試行 3)へ寄せる - test_records_at_once_never_skip_a_stage の試行を 8 → 4 回、test_lock_held_passes を 4 → 3 回にする Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G5uEPdcjhFR1Aerv5JMfpP --- .github/pull_request_template.md | 2 +- .github/workflows/pytest.yml | 10 +++- CONTRIBUTING.md | 4 +- docs/plugin-development-guide.md | 6 +-- ...f-worktree-declaration-and-entry-points.md | 2 +- .../test-monitor-env-isolation.md | 2 +- issues/issue-882-884-test-speedup.md | 31 +++++++++++ plugins/ndf/scripts/tests/test_token_guard.py | 4 +- .../tests/test_stage_check.py | 5 +- .../skills/worktree/tests/test_registry.py | 20 +++---- scripts/tests/test_lock_common.py | 53 ++----------------- 11 files changed, 68 insertions(+), 71 deletions(-) create mode 100644 issues/issue-882-884-test-speedup.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 27c45c346..3c0acf135 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ -- [ ] `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q` +- [ ] `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto` - [ ] `python3 scripts/check-skill-frontmatter.py` - [ ] `python3 scripts/check-doc-staleness.py` - [ ] `python3 scripts/check-markdown-links.py --root .` diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5082d3bec..8863d350a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -5,6 +5,10 @@ name: Python tests # 「成功」ではなく「実行されていない」として扱うため、絞り込んだままだと対象外の変更で # マージ可能にならない。push の側は絞り込みを残す(マージの可否を決めないため) # +# push の branches に release/** を置かない。release/** から出した Pull Request では +# pull_request の側が必ず回るため、push の側も起動すると同じテストを 2 回回す(#884)。 +# main / develop はマージ後の確認として残す +# # push の絞り込みは pytest . の収集範囲に合わせる。テストの置き場所(plugins / scripts / # tests)に加えて、収集の仕方そのものを変える根の設定ファイルも対象へ入れる on: @@ -13,7 +17,6 @@ on: branches: - main - develop - - "release/**" paths: - ".github/workflows/pytest.yml" - "plugins/**" @@ -38,4 +41,7 @@ jobs: # # 前提のコマンドの確認と git の身元の設定は、根の conftest.py が行う。前提を # ワークフローの側にも置くと、増えたときに片方だけが更新される - - run: uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q + # + # -n auto で runner のコア数だけ並列に回す(#882)。pytest-xdist は --project が指す + # pyproject.toml の依存に入っている + - run: uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b98ef4bb6..25f3cd131 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,8 +60,8 @@ cd .worktrees/feature/<名前> 検査です。リポジトリの根から実行します。 ```bash -# 1. テスト(継続的統合と同じ範囲を 1 回の起動で回す) -uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q +# 1. テスト(継続的統合と同じ範囲を 1 回の起動で回す。-n auto でコア数だけ並列に回す) +uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto # 2. Skill の frontmatter の規約 python3 scripts/check-skill-frontmatter.py diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 3b5f520ba..eac548526 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -216,7 +216,7 @@ bash scripts/build-runtime-plugins.sh --check bash scripts/validate-runtime-plugins.sh python3 scripts/check-doc-staleness.py --root . python3 scripts/check-skill-frontmatter.py -uv run --with pytest pytest scripts/tests -q +uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto ``` ## 既存プラグインの削除 @@ -275,10 +275,10 @@ bash scripts/validate-runtime-plugins.sh `scripts/` 自体を変更した場合は、その検査のテストも実行します。 ```bash -uv run --with pytest pytest scripts/tests -q +uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto ``` -`--with pytest` を省くと `Failed to spawn: pytest` で終わります。リポジトリの根に uv の +`--with pytest` を省くと `Failed to spawn: pytest` で終わります。`-n auto` は `pytest-xdist` の指定で、コア数だけ並列に回します(`--with pytest-xdist` を省くと `-n` を解釈できずに終わります)。リポジトリの根に uv の 対象プロジェクト(`pyproject.toml`)が無く、`pytest` が環境にも入っていないためです。 `plugins/ndf/skills/*/tests/` の既存のテストも同じ形で実行します。 diff --git a/docs/specifications/ndf-worktree-declaration-and-entry-points.md b/docs/specifications/ndf-worktree-declaration-and-entry-points.md index cfc10eadf..b03e44fe6 100644 --- a/docs/specifications/ndf-worktree-declaration-and-entry-points.md +++ b/docs/specifications/ndf-worktree-declaration-and-entry-points.md @@ -353,7 +353,7 @@ clone した全員で同じ値のまま残る。 | 区切り・プロセス置換・引用符の中の `<` で出力が変わらない | 同上 | | 手順 0 が `init` の失敗で止まることが本文に書かれている | 同 `test_setup.py`(本文の抜き出し) | -実行は `uv run --with pytest pytest scripts/tests plugins/ndf -q`。配布物の同期は +実行は `uv run --with pytest --with pytest-xdist pytest scripts/tests plugins/ndf -q -n auto`。配布物の同期は `bash scripts/build-runtime-plugins.sh --check`、定義の検査は `claude plugin validate .` で見る。 ## 運用 diff --git a/docs/specifications/test-monitor-env-isolation.md b/docs/specifications/test-monitor-env-isolation.md index ade97f37a..41c2d9ceb 100644 --- a/docs/specifications/test-monitor-env-isolation.md +++ b/docs/specifications/test-monitor-env-isolation.md @@ -56,7 +56,7 @@ | 観点 | 確かめ方 | | --- | --- | -| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドを比べる | +| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest --with pytest-xdist pytest scripts/tests plugins/ndf -q -n auto` と、設定しない同じコマンドを比べる | | 実行中のテストに接頭辞 `MONITOR_` の環境変数が 1 つも残らないこと | `scripts/tests/test_root_conftest.py` | | テストが自分で設定した値は観測できること | 同上 | | 別プロセスへ受け継がれないこと | 同上 | diff --git a/issues/issue-882-884-test-speedup.md b/issues/issue-882-884-test-speedup.md new file mode 100644 index 000000000..d032a707b --- /dev/null +++ b/issues/issue-882-884-test-speedup.md @@ -0,0 +1,31 @@ +# #882 #884 テストの高速化(要求と受け入れ条件) + +モード: `light`(テストと継続的統合の設定、テストを案内する文書だけを触る。本番のスクリプトは変えない) + +## 要求 + +全体テストを回すたびの待ちを縮める。手元・継続的統合・`cross-refactoring` の `--baseline-test` の +どれでも、全体テストの所要がそのまま待ちになっている(#880)。 + +## 範囲 + +| 課題 | この変更で行う | 行わない(理由と行き先) | +| --- | --- | --- | +| #882 | 継続的統合の全体テストを `-n auto` で並列に回す。案内するコマンド(`CONTRIBUTING.md`・PR テンプレート・`docs/plugin-development-guide.md`・確定仕様 2 件)を並列の形にする。並列で壊れるテストを直す | ジョブの分割(段 2)は、並列にした後の継続的統合の所要が 2 分を超えたときだけ行う | +| #884 | 競合試験の重複を共通実装への 1 通りへ寄せる。繰り返しの回数を目的に必要な数まで減らす(`test_records_at_once_never_skip_a_stage` 8 → 4 回、`test_lock_held_passes` 4 → 3 回)。`release/**` の push 契機を外し、継続的統合の 2 重実行をやめる | `bg-wait.sh` のポーリング間隔(本番のスクリプトの変更)、ロックの上限 5 秒の注入(本番のスクリプトの変更。#293 の決定に関わる)、`test_git_facts.py` の猶予待ち(#883) | + +## 受け入れ条件 + +| # | 条件 | 確かめ方 | +| --- | --- | --- | +| AC1 | 継続的統合の pytest ジョブが `-n auto` で走り、全件合格する | Pull Request の `pytest` の検査 | +| AC2 | 全体テストを案内するコマンドがすべて並列の形になっている | `grep -rn "pytest scripts/tests plugins/ndf -q\|pytest \. -q"`(`issues/` と記録の文書を除く) | +| AC3 | 手元の並列実行(`-n 4`)で全件合格し、変更前の順の実行より速い | 前後の所要を実測して PR 本文へ載せる | +| AC4 | 継続的統合の所要が変更前(テスト 5 分 30 秒)の半分以下 | Pull Request の実行時間 | +| AC5 | 寄せた競合試験が、臨界区間の重なりを変更後も失敗として検出する | `ndf_lock_acquire` の排他を外した状態で `test_many_at_once_never_share_the_critical_section` が落ちることを 1 度確かめる | +| AC6 | 減らした繰り返しのテストが、見ている不具合を変更後も検出する | 同上の手順で、対象の排他を外すと落ちることを確かめる | +| AC7 | `release/**` からの Pull Request で `Python tests` が 1 回だけ起動する | `on.push.branches` に `release/**` が無い | + +#884 の受け入れ条件「0.5 秒以上のテストの合計が 40 秒以下」は、この変更だけでは満たさない。 +残る大口(`test_git_facts.py` 21 秒・`test_bg_wait.py` 19 秒・ロックの上限を待つテスト)は +本番のスクリプトの変更を要するため、#884 は開いたまま残し、残りを本文へ記録する。 diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 57f1a9073..cb5c1d0cf 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -362,7 +362,9 @@ def test_lock_held_passes(tmp_path, state): (lock / "held").write_text("") (lock / "pid").write_text(str(os.getpid())) (lock / "token").write_text("t") - for _ in range(4): + # 3 回目は排他を取れれば拒否される回数である(test_repeat_read_denied_on_third)。 + # 1 回ごとに排他の上限(1 秒)を待つため、それを越えて回さない(#884) + for _ in range(3): assert denied(run(read(f), state)) is None diff --git a/plugins/ndf/skills/development-workflow/tests/test_stage_check.py b/plugins/ndf/skills/development-workflow/tests/test_stage_check.py index e7b9d88b4..83dadae96 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_stage_check.py +++ b/plugins/ndf/skills/development-workflow/tests/test_stage_check.py @@ -283,7 +283,8 @@ def test_records_at_once_never_skip_a_stage(repo: Path, state: Path) -> None: """#308-2 / 3: 同時の記録を繰り返しても工程が欠けず、飛ばした記録も出ない。 1 回の実行では取りこぼしがあっても通ることがあるため、繰り返して件数で見る。 - 受け入れ条件が求める 60 回は完了判定として手元で回す。 + 受け入れ条件が求める 60 回は完了判定として手元で回す。ここでの試行は 4 回にする + (#884。8 回から減らした。4 つの記録が同時に走る試行を繰り返す目的は変わらない)。 """ stages = ("設計", "計画", "実装", "実装レビュー") env = base_env(state, {"NDF_STAGE_LOCK_TIMEOUT": "20"}) @@ -291,7 +292,7 @@ def test_records_at_once_never_skip_a_stage(repo: Path, state: Path) -> None: short = [] skipped = 0 - for trial in range(8): + for trial in range(4): issue = 3080 + trial procs = [ subprocess.Popen( diff --git a/plugins/ndf/skills/worktree/tests/test_registry.py b/plugins/ndf/skills/worktree/tests/test_registry.py index f6e846383..ff4c757a3 100644 --- a/plugins/ndf/skills/worktree/tests/test_registry.py +++ b/plugins/ndf/skills/worktree/tests/test_registry.py @@ -179,7 +179,8 @@ def test_broken_registry_is_treated_as_empty(main_repo: Path) -> None: WF_LIB = Path(__file__).resolve().parents[3] / "skills/development-workflow/scripts/lib/workflow-common.sh" # 実装は `scripts/lib/lock-common.sh` の 1 箇所にあり、2 つの読み込む側が既存の名前へ -# 結んでいる(#293)。**両方の読み込む側へ同じ検査をかける。** +# 結んでいる(#293)。**両方の読み込む側へ同じ検査をかける。** 例外は競合試験で、 +# 共通実装に対して 1 通りだけ回す(下の `test_many_at_once_never_share_the_critical_section`)。 LOCK_LIBS = [ pytest.param(LIB, "wt_lock_acquire", "wt_lock_release", id="worktree"), pytest.param(WF_LIB, "wf_lock_acquire", "wf_lock_release", id="workflow"), @@ -248,17 +249,18 @@ def _run_lock_race( return result -@pytest.mark.parametrize(("parallel", "trials"), [(6, 7), (12, 3)]) -@pytest.mark.parametrize(("lib", "acquire", "release"), LOCK_LIBS) -def test_many_at_once_never_share_the_critical_section( - tmp_path: Path, lib: Path, acquire: str, release: str, parallel: int, trials: int -) -> None: +def test_many_at_once_never_share_the_critical_section(tmp_path: Path) -> None: """#297-1 / 2 / 3 と #308-5 を 1 つの測定で見る。 - 並列数を 6 と 12 で変えても結果が変わらないことが、持ち主の決定が時間に依らない - ことの担保になる。 + **競合試験は共通実装に対して 1 通りだけ回す**(#884)。2 つの入口はどちらも + `ndf_lock_acquire` を 1 行で呼ぶだけで、入口が共通実装へ届くことは + `scripts/tests/test_lock_common.py::test_the_existing_names_take_and_release_the_lock` + が見ている。入口 × 並列数の 4 通りで同じ臨界区間を試しても、検出できる不具合は増えない。 + 並列数は多い側(12)を残す。重なりは同時に取りに行く数が多いほど出やすい。 """ - got = _run_lock_race(tmp_path, lib, acquire, release, parallel=parallel, trials=trials) + got = _run_lock_race( + tmp_path, LIB, "wt_lock_acquire", "wt_lock_release", parallel=12, trials=3 + ) assert got["overlap"] == 0, f"臨界区間が重なった試行 {got['overlap']} 件" assert got["miss"] == 0, f"上限に達して取れなかった回数 {got['miss']} 回" diff --git a/scripts/tests/test_lock_common.py b/scripts/tests/test_lock_common.py index f35e48e5a..27b2fdde8 100644 --- a/scripts/tests/test_lock_common.py +++ b/scripts/tests/test_lock_common.py @@ -241,55 +241,10 @@ def test_a_missing_common_file_stops_the_write_but_not_the_step(tmp_path: Path) # --- A7: 同時に走らせても持ち主は 1 つ -------------------------------------- - -LOCK_WORKER = """#!/usr/bin/env bash -# $1 共通ファイル / $2 置き場所 -set -uo pipefail -. "$1" -while [ ! -e "$2/go" ]; do :; done -if ndf_lock_acquire "$2/lock" 6; then - : >"$2/in.$$" - if [ "$(ls "$2"/in.* 2>/dev/null | wc -l)" -gt 1 ]; then : >"$2/over.$$"; fi - sleep 0.02 - rm -f "$2/in.$$" - ndf_lock_release "$2/lock" -else - : >"$2/miss.$$" -fi -exit 0 -""" - - -def test_six_at_once_leave_one_owner(tmp_path: Path) -> None: - """A7: 6 つが同時に取りに行っても、臨界区間は重ならず取りこぼしも出ない。 - - `mkdir` コマンドは同じ名前の作成に複数を通す。1 回の実行では取りこぼしがあっても - 通ることがあるため、繰り返したうえで件数で見る。 - """ - worker = tmp_path / "worker.sh" - worker.write_text(LOCK_WORKER, encoding="utf-8") - base = tmp_path / "race" - base.mkdir() - overlap = miss = 0 - - for _ in range(7): - for stray in base.iterdir(): - shutil.rmtree(stray, ignore_errors=True) if stray.is_dir() else stray.unlink() - procs = [ - subprocess.Popen( - ["bash", str(worker), str(LOCK_LIB), str(base)], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - for _ in range(6) - ] - (base / "go").touch() - for proc in procs: - proc.wait() - overlap += 1 if any(base.glob("over.*")) else 0 - miss += len(list(base.glob("miss.*"))) - - assert overlap == 0, f"臨界区間が重なった試行 {overlap} 件" - assert miss == 0, f"上限に達して取れなかった回数 {miss} 回" +# +# 競合試験は `worktree/tests/test_registry.py::test_many_at_once_never_share_the_critical_section` +# が共通実装に対して 1 通りだけ回す(#884)。同じ臨界区間をここでも試しても、検出できる +# 不具合は増えない。入口が共通実装へ届くことは上の A2 / A3 が見ている。 # --- A8: 呼び出し側のシェルの状態を変えない --------------------------------- From 89ff29f5fa4e9e2ffe9ab870019db2cf26c0c6ed Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 05:56:29 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Test:=20=E7=B6=99=E7=B6=9A=E7=9A=84?= =?UTF-8?q?=E7=B5=B1=E5=90=88=E3=81=AE=E5=85=A8=E4=BD=93=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=8D=98?= =?UTF-8?q?=E4=BD=8D=E3=81=A7=202=20=E3=81=A4=E3=81=AE=E3=82=B8=E3=83=A7?= =?UTF-8?q?=E3=83=96=E3=81=B8=E5=88=86=E3=81=91=E3=82=8B=EF=BC=88#882?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 並列にした後もジョブが 2 分 6 秒で 2 分を超えたため、#882 の段 2 を行う。 根の conftest.py が SHARD_TOTAL / SHARD_INDEX からファイルのパスの剰余で自分の分を選び、 まとめジョブ pytest が ruleset の必須の検査として 1 つの結果を返す。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G5uEPdcjhFR1Aerv5JMfpP --- .github/workflows/pytest.yml | 30 ++++++++++++++++----- conftest.py | 40 ++++++++++++++++++++++++++++ issues/issue-882-884-test-speedup.md | 3 ++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 8863d350a..8a44326ee 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -27,8 +27,16 @@ on: - "pyproject.toml" jobs: - pytest: + # ファイル単位で 2 つのジョブへ分けて回す(#882)。割り当ては根の conftest.py が + # SHARD_TOTAL / SHARD_INDEX から決める。各ジョブの中は -n auto で runner のコア数だけ + # 並列に回す(pytest-xdist は --project が指す pyproject.toml の依存に入っている) + pytest-shard: + name: pytest (${{ matrix.shard }}/2) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [0, 1] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -36,12 +44,22 @@ jobs: python-version: "3.x" - name: Install uv run: python3 -m pip install --upgrade uv - # 起点はリポジトリの根に置き、1 回の起動で全件を回す。playwright-kit のテストは - # その skill の pyproject.toml が宣言する依存を使うため、--project でそこを指す。 + # 起点はリポジトリの根に置く。playwright-kit のテストはその skill の pyproject.toml が + # 宣言する依存を使うため、--project でそこを指す。 # # 前提のコマンドの確認と git の身元の設定は、根の conftest.py が行う。前提を # ワークフローの側にも置くと、増えたときに片方だけが更新される - # - # -n auto で runner のコア数だけ並列に回す(#882)。pytest-xdist は --project が指す - # pyproject.toml の依存に入っている - run: uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto + env: + SHARD_TOTAL: 2 + SHARD_INDEX: ${{ matrix.shard }} + + # ruleset の必須の検査は `pytest` という名前の結果を 1 つ待つ。分けたジョブの結果を + # ここで 1 つにまとめる。名前を変えると ruleset の変更が要る。 + # always() で分けたジョブが落ちても走らせ、失敗・取り消しを失敗として返す + pytest: + needs: pytest-shard + if: always() + runs-on: ubuntu-latest + steps: + - run: test "${{ needs.pytest-shard.result }}" = success diff --git a/conftest.py b/conftest.py index ccf49f53e..ac5e4f4b4 100644 --- a/conftest.py +++ b/conftest.py @@ -12,6 +12,8 @@ 状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72) 5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) +6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、 + 自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882) どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は 根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 @@ -24,6 +26,7 @@ import os import shutil import tempfile +import zlib from pathlib import Path import pytest @@ -122,7 +125,44 @@ def pytest_unconfigure(config) -> None: _saved_monitor_env.clear() +# ジョブの分割(#882)。**分ける単位はファイルで、割り当てはパスの剰余だけで決める。** +# 同じファイルのテストは同じジョブへ入るため、ファイル単位の前提(モジュールの読み込み・ +# 一時ディレクトリ)が割れない。偏りが出たら実測時間での割り当てへ進む。 +SHARD_TOTAL_ENV = "SHARD_TOTAL" +SHARD_INDEX_ENV = "SHARD_INDEX" + + +def _shard() -> tuple[int, int] | None: + """`(index, total)` を返す。指定が無ければ `None`(分けない)。""" + total = os.environ.get(SHARD_TOTAL_ENV, "") + if total == "": + return None + index = os.environ.get(SHARD_INDEX_ENV, "") + if not (total.isdigit() and index.isdigit() and int(index) < int(total)): + raise pytest.UsageError( + f"{SHARD_TOTAL_ENV}={total!r} / {SHARD_INDEX_ENV}={index!r}: " + f"0 <= {SHARD_INDEX_ENV} < {SHARD_TOTAL_ENV} の整数を指定してください" + ) + return int(index), int(total) + + +def _select_shard(config, items) -> None: + shard = _shard() + if shard is None: + return + index, total = shard + kept, dropped = [], [] + for item in items: + path = Path(str(item.fspath)).resolve() + rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix() + (kept if zlib.crc32(rel.encode()) % total == index else dropped).append(item) + if dropped: + config.hook.pytest_deselected(items=dropped) + items[:] = kept + + def pytest_collection_modifyitems(config, items) -> None: + _select_shard(config, items) bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None} missing = _missing(bundles) if not missing: diff --git a/issues/issue-882-884-test-speedup.md b/issues/issue-882-884-test-speedup.md index d032a707b..06610d395 100644 --- a/issues/issue-882-884-test-speedup.md +++ b/issues/issue-882-884-test-speedup.md @@ -11,7 +11,7 @@ | 課題 | この変更で行う | 行わない(理由と行き先) | | --- | --- | --- | -| #882 | 継続的統合の全体テストを `-n auto` で並列に回す。案内するコマンド(`CONTRIBUTING.md`・PR テンプレート・`docs/plugin-development-guide.md`・確定仕様 2 件)を並列の形にする。並列で壊れるテストを直す | ジョブの分割(段 2)は、並列にした後の継続的統合の所要が 2 分を超えたときだけ行う | +| #882 | 継続的統合の全体テストを `-n auto` で並列に回す。案内するコマンド(`CONTRIBUTING.md`・PR テンプレート・`docs/plugin-development-guide.md`・確定仕様 2 件)を並列の形にする。並列で壊れるテストを直す。並列にした後の継続的統合のジョブが 2 分 6 秒(テスト 1 分 53 秒)で 2 分を超えたため、ファイル単位の 2 分割(根の `conftest.py` の `SHARD_TOTAL` / `SHARD_INDEX`)とまとめジョブ `pytest` を置く(段 2) | — | | #884 | 競合試験の重複を共通実装への 1 通りへ寄せる。繰り返しの回数を目的に必要な数まで減らす(`test_records_at_once_never_skip_a_stage` 8 → 4 回、`test_lock_held_passes` 4 → 3 回)。`release/**` の push 契機を外し、継続的統合の 2 重実行をやめる | `bg-wait.sh` のポーリング間隔(本番のスクリプトの変更)、ロックの上限 5 秒の注入(本番のスクリプトの変更。#293 の決定に関わる)、`test_git_facts.py` の猶予待ち(#883) | ## 受け入れ条件 @@ -25,6 +25,7 @@ | AC5 | 寄せた競合試験が、臨界区間の重なりを変更後も失敗として検出する | `ndf_lock_acquire` の排他を外した状態で `test_many_at_once_never_share_the_critical_section` が落ちることを 1 度確かめる | | AC6 | 減らした繰り返しのテストが、見ている不具合を変更後も検出する | 同上の手順で、対象の排他を外すと落ちることを確かめる | | AC7 | `release/**` からの Pull Request で `Python tests` が 1 回だけ起動する | `on.push.branches` に `release/**` が無い | +| AC8 | 分割しても ruleset の必須の検査 `pytest` が 1 つの結果として返り、ruleset を変えずにマージできる | Pull Request の検査の一覧で `pytest` が合格し、マージ可能になる | #884 の受け入れ条件「0.5 秒以上のテストの合計が 40 秒以下」は、この変更だけでは満たさない。 残る大口(`test_git_facts.py` 21 秒・`test_bg_wait.py` 19 秒・ロックの上限を待つテスト)は From 08540b27e929787c6a424813423e9f3bf33985b4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 06:06:25 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Test:=20shard=20=E5=88=86=E5=89=B2=E3=81=8C?= =?UTF-8?q?=E9=A0=85=E7=9B=AE=E3=82=92=E6=AC=A0=E3=81=8B=E3=81=95=E3=81=9A?= =?UTF-8?q?=E9=87=8D=E3=81=AD=E3=81=9A=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=92=E5=89=B2=E3=82=89=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHARD_TOTAL=2 の各 shard と未分割の収集を実際に比べ、和の一致・重複なし・ 同一ファイルが複数 shard に割れないことを検証する(#891 のレビュー指摘)。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G5uEPdcjhFR1Aerv5JMfpP --- scripts/tests/test_root_conftest.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index f575da6db..7ef4029b5 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -279,3 +279,36 @@ def test_the_prefix_is_declared_once() -> None: assert 'MONITOR_ENV_PREFIX = "MONITOR_"' in body assert "def pytest_unconfigure" in body + + +# ジョブの分割(#882)。**分けても項目が欠けず、重ならず、ファイルが割れないこと**を、 +# 実際の収集で確かめる。分割が項目を落としても各ジョブと集約のジョブは成功し得るため、 +# 継続的統合の結果だけでは退行に気づけない。 +SHARD_TARGET = "scripts/tests" +SHARD_TOTAL = 2 + + +def _collected_ids(shard: tuple[int, int] | None) -> list[str]: + env = {k: v for k, v in os.environ.items() if k not in ("SHARD_TOTAL", "SHARD_INDEX")} + if shard is not None: + env["SHARD_INDEX"], env["SHARD_TOTAL"] = str(shard[0]), str(shard[1]) + proc = subprocess.run( + [sys.executable, "-m", "pytest", SHARD_TARGET, "--collect-only", "-q", + "--no-header", "-p", "no:cacheprovider", "-p", "no:xdist"], + cwd=str(REPO_ROOT), capture_output=True, text=True, env=env, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + return [line for line in proc.stdout.splitlines() if "::" in line] + + +def test_the_shards_cover_every_item_exactly_once_without_splitting_a_file() -> None: + full = _collected_ids(None) + shards = [_collected_ids((i, SHARD_TOTAL)) for i in range(SHARD_TOTAL)] + + assert all(shards), "どれかの shard が空で、分割を確かめられない" + merged = [nodeid for ids in shards for nodeid in ids] + assert len(merged) == len(set(merged)), "同じ項目が 2 つの shard に入った" + assert sorted(merged) == sorted(full), "shard の和が未分割の収集と一致しない" + + files = [{nodeid.split("::", 1)[0] for nodeid in ids} for ids in shards] + assert not set.intersection(*files), "同じファイルが複数の shard に割れた"