Skip to content

Commit afda20e

Browse files
takemi-ohamaclaude
andcommitted
perf(status): コンテナ状態を docker ps 1 回集計に変更 (N→1 サブプロセス)
devbase list / status の状態取得が「プロジェクト数ぶん docker compose ps を サブプロセス起動」していたのを、単一の docker ps で全 running コンテナを com.docker.compose.project ラベルごとに集計する方式へ変更する。 - _running_counts_by_project() を追加: docker ps を 1 回だけ実行し {project名: running数} を返す。 - _container_status_for(entry, counts) は counts マップを参照するだけに変更。 呼び出し側 (_get_container_status / list_projects) が 1 回集計して全 entry で 使い回す。ラベルで識別するため COMPOSE_PROJECT_NAME 継承の影響も構造的に 受けなくなり、前コミットの --project-name scope は不要になった。 - list_projects の ThreadPoolExecutor を廃止 (単一 docker ps なので並列化不要)。 - status.py の未使用 json import を削除。 プロジェクト数が増えてもサブプロセス起動は常に 1 回で済む。 テストも docker ps 集計方式の検証へ更新 (起動回数 1 回・ラベル集計・env 非依存)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d1c826a commit afda20e

4 files changed

Lines changed: 190 additions & 117 deletions

File tree

lib/devbase/commands/project.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ def list_projects(projects_dir: Path) -> list[dict]:
6868
"""
6969
# status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry
7070
# 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。
71-
from concurrent.futures import ThreadPoolExecutor
72-
7371
from devbase.commands import status as status_mod
7472

7573
if not projects_dir.exists():
@@ -80,31 +78,28 @@ def list_projects(projects_dir: Path) -> list[dict]:
8078
entry for entry in sorted(projects_dir.iterdir())
8179
if entry.is_symlink() or entry.is_dir()
8280
]
81+
if not entries:
82+
return []
83+
84+
# コンテナ状態は docker ps 1 回で全プロジェクトぶん集計し (counts)、各 entry で
85+
# 使い回す。プロジェクト数ぶん docker compose ps を起動していた旧実装の
86+
# サブプロセスコストを N→1 に削減するため、並列化 (ThreadPoolExecutor) も不要。
87+
counts = status_mod._running_counts_by_project()
8388

8489
def _status_for(entry: Path) -> str:
8590
# is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。
86-
# _container_status_for は cwd= 引数で完結し global chdir を行わないため
87-
# スレッド安全。各 `docker compose ps` は I/O バウンドで 10s timeout を
88-
# 持つため、プロジェクト数が増えても並列化で総待ち時間を抑える。
8991
if not entry.is_dir():
9092
return "unknown"
91-
st = status_mod._container_status_for(entry)
93+
st = status_mod._container_status_for(entry, counts)
9294
return st["status"] if st is not None else "unknown"
9395

94-
# entries が空だと max_workers=0 で ValueError になるため早期 return。
95-
if not entries:
96-
return []
97-
98-
with ThreadPoolExecutor(max_workers=min(8, len(entries))) as ex:
99-
statuses = list(ex.map(_status_for, entries))
100-
10196
return [
10297
{
10398
"name": entry.name,
10499
"plugin": _resolve_plugin_name(entry) or "-",
105-
"status": status,
100+
"status": _status_for(entry),
106101
}
107-
for entry, status in zip(entries, statuses)
102+
for entry in entries
108103
]
109104

110105

lib/devbase/commands/status.py

Lines changed: 60 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""devbase status - 環境ステータスの一覧表示"""
22

3-
import json
43
import subprocess
54
from datetime import datetime
65
from pathlib import Path
@@ -16,90 +15,93 @@
1615
logger = get_logger(__name__)
1716

1817

19-
def _container_status_for(entry: Path) -> dict | None:
20-
"""単一プロジェクトディレクトリのコンテナ状態を取得する。
18+
_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
2119

22-
`projects/<name>` (実ディレクトリ or plugin への symlink) を受け取り、
23-
``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker
24-
コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。
20+
# `counts` 引数の「未指定」を docker 不在 (None) と区別するための sentinel。
21+
_UNSET = object()
2522

26-
PLAN06 で ``project list`` (commands/project.py) が同じ per-entry ロジックを
27-
再利用するため、``_get_container_status`` のループ本体から分離した。挙動は
28-
分離前と同一 (None を返す条件 = 旧実装で ``continue`` していた条件)。
29-
"""
30-
compose_file = entry / "compose.yml"
31-
if not compose_file.exists():
32-
return None
3323

24+
def _running_counts_by_project() -> dict[str, int] | None:
25+
"""全 running コンテナを単一の ``docker ps`` で取得し、compose project 名
26+
ごとの running 数を返す。docker が使えない / 取得失敗時は ``None``。
27+
28+
プロジェクト数ぶん ``docker compose ps`` を起動する代わりに ``docker ps``
29+
1 回で全コンテナのラベルを集計し、サブプロセス起動コストを N→1 に削減する。
30+
compose project は ``com.docker.compose.project`` ラベル (= devbase up 時の
31+
COMPOSE_PROJECT_NAME = プロジェクト名) で識別するため、呼び出し側プロセスが
32+
継承する COMPOSE_PROJECT_NAME に一切影響されない (一覧が一律同一状態になる
33+
回帰を構造的に回避する)。``docker ps`` は既定で running のみを列挙する。
34+
"""
3435
try:
35-
# `--project-name entry.name` で明示 scope する。bin/devbase が常に
36-
# COMPOSE_PROJECT_NAME を export しており、これを継承したまま
37-
# `docker compose ps` を叩くと docker compose は継承 env を
38-
# ディレクトリ由来名より優先するため、全プロジェクトがカレント
39-
# プロジェクトの状態を返してしまう (一覧が一律 running / 同一コンテナ数
40-
# になる回帰)。devbase up は COMPOSE_PROJECT_NAME = 各プロジェクト名
41-
# (= entry.name) でコンテナを起動するため、同じ名前で scope する。
4236
proc = subprocess.run(
43-
["docker", "compose", "--project-name", entry.name,
44-
"ps", "--format", "json"],
45-
cwd=str(entry),
37+
["docker", "ps",
38+
"--filter", f"label={_COMPOSE_PROJECT_LABEL}",
39+
"--format", f'{{{{.Label "{_COMPOSE_PROJECT_LABEL}"}}}}'],
4640
capture_output=True,
4741
text=True,
4842
timeout=10,
4943
)
5044
if proc.returncode != 0:
5145
return None
46+
except (subprocess.TimeoutExpired, OSError):
47+
# docker コマンドが利用できない、またはタイムアウト
48+
return None
5249

53-
output = proc.stdout.strip()
54-
if not output:
55-
return {"name": entry.name, "status": "stopped", "count": 0}
56-
57-
# docker compose ps --format json は1行1JSONまたはJSON配列
58-
containers = []
59-
for line in output.splitlines():
60-
line = line.strip()
61-
if not line:
62-
continue
63-
try:
64-
parsed = json.loads(line)
65-
if isinstance(parsed, list):
66-
containers.extend(parsed)
67-
else:
68-
containers.append(parsed)
69-
except json.JSONDecodeError:
70-
continue
71-
72-
if not containers:
73-
return {"name": entry.name, "status": "stopped", "count": 0}
74-
75-
running = sum(
76-
1 for c in containers
77-
if c.get("State", "").lower() == "running"
78-
)
79-
total = len(containers)
50+
counts: dict[str, int] = {}
51+
for line in proc.stdout.splitlines():
52+
name = line.strip()
53+
if name:
54+
counts[name] = counts.get(name, 0) + 1
55+
return counts
8056

81-
if running > 0:
82-
status = f"running ({total} containers)"
83-
else:
84-
status = "stopped"
8557

86-
return {"name": entry.name, "status": status, "count": total}
58+
def _container_status_for(entry: Path, counts=_UNSET) -> dict | None:
59+
"""単一プロジェクトディレクトリのコンテナ状態を取得する。
8760
88-
except (subprocess.TimeoutExpired, OSError):
89-
# dockerコマンドが利用できない、またはタイムアウト
61+
`projects/<name>` (実ディレクトリ or plugin への symlink) を受け取り、
62+
``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker
63+
コマンドが利用できない場合は ``None`` を返す。
64+
65+
``counts`` には ``_running_counts_by_project()`` の戻り値 (compose project 名
66+
→ running 数) を渡す。一覧表示では呼び出し側が 1 回だけ集計して全 entry で
67+
使い回すことで docker サブプロセスの起動を 1 回に抑える。``counts`` を省略
68+
した単発呼び出しでは本関数内で都度集計する。``None`` (docker 不在) が明示的に
69+
渡された場合は再集計せず ``None`` を返す。
70+
"""
71+
compose_file = entry / "compose.yml"
72+
if not compose_file.exists():
73+
return None
74+
75+
if counts is _UNSET:
76+
counts = _running_counts_by_project()
77+
if counts is None:
78+
# docker が利用できない / 取得失敗
9079
return None
9180

81+
# devbase up は COMPOSE_PROJECT_NAME = entry.name でコンテナを起動するため、
82+
# compose project ラベルが entry.name の running 数がこのプロジェクトの稼働数。
83+
running = counts.get(entry.name, 0)
84+
if running > 0:
85+
status = f"running ({running} containers)"
86+
else:
87+
status = "stopped"
88+
89+
return {"name": entry.name, "status": status, "count": running}
90+
9291

9392
def _get_container_status(projects_dir: Path) -> list[dict]:
9493
"""projects/ 配下の各プロジェクトのコンテナ状態を取得する"""
9594
results = []
9695
if not projects_dir.exists():
9796
return results
9897

98+
# docker ps は 1 回だけ実行し、全 entry で使い回す。
99+
counts = _running_counts_by_project()
100+
99101
for entry in sorted(projects_dir.iterdir()):
100102
if not entry.is_dir():
101103
continue
102-
status = _container_status_for(entry)
104+
status = _container_status_for(entry, counts)
103105
if status is not None:
104106
results.append(status)
105107

tests/cli/test_project_list.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def test_list_projects_enumerates_name_plugin_status(tmp_path, monkeypatch):
144144
_link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj")
145145

146146
# status は docker に依存させず固定値を返す
147-
def fake_status(entry: Path):
147+
def fake_status(entry, counts=None):
148148
return {"name": entry.name, "status": "running (2 containers)", "count": 2}
149149

150150
monkeypatch.setattr(status_mod, "_container_status_for", fake_status)
@@ -165,7 +165,7 @@ def test_list_projects_unknown_status_when_none(tmp_path, monkeypatch):
165165
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
166166
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
167167

168-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
168+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
169169

170170
rows = project_mod.list_projects(tmp_path / "projects")
171171
assert rows[0]["status"] == "unknown"
@@ -179,7 +179,7 @@ def test_list_projects_real_dir_plugin_dash(tmp_path, monkeypatch):
179179
projects_dir.mkdir()
180180
(projects_dir / "standalone").mkdir()
181181

182-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
182+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
183183

184184
rows = project_mod.list_projects(projects_dir)
185185
assert rows[0]["name"] == "standalone"
@@ -202,7 +202,7 @@ def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys):
202202
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
203203
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
204204
monkeypatch.setattr(status_mod, "_container_status_for",
205-
lambda entry: {"name": entry.name, "status": "stopped", "count": 0})
205+
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
206206

207207
args = types.SimpleNamespace(interactive=False)
208208
rc = project_mod.cmd_project_list(tmp_path, args)
@@ -231,7 +231,7 @@ def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, cap
231231
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
232232
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
233233
monkeypatch.setattr(status_mod, "_container_status_for",
234-
lambda entry: {"name": entry.name, "status": "stopped", "count": 0})
234+
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
235235
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False)
236236

237237
called = []
@@ -256,7 +256,7 @@ def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypat
256256
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
257257
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
258258
monkeypatch.setattr(status_mod, "_container_status_for",
259-
lambda entry: {"name": entry.name, "status": "stopped", "count": 0})
259+
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
260260
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
261261
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False)
262262

@@ -285,7 +285,7 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch):
285285
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
286286
_make_plugin_project(tmp_path, "plugins/beta", "beta-proj")
287287
_link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj")
288-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
288+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
289289

290290
# 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。
291291
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
@@ -313,7 +313,7 @@ def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch):
313313

314314
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
315315
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
316-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
316+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
317317
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
318318
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
319319
monkeypatch.setattr("builtins.input", lambda *a, **k: "")
@@ -335,7 +335,7 @@ def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch):
335335

336336
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
337337
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
338-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
338+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
339339

340340
def raise_eof(*a, **k):
341341
raise EOFError
@@ -360,7 +360,7 @@ def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkey
360360

361361
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
362362
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
363-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
363+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
364364

365365
def raise_interrupt(*a, **k):
366366
raise KeyboardInterrupt
@@ -385,7 +385,7 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat
385385

386386
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
387387
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
388-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
388+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
389389

390390
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
391391
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
@@ -410,7 +410,7 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc
410410

411411
_make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj")
412412
_link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj")
413-
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None)
413+
monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None)
414414

415415
monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True)
416416
monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True)
@@ -559,7 +559,7 @@ def test_get_container_status_uses_per_entry(tmp_path, monkeypatch):
559559
(projects_dir / "b").mkdir()
560560

561561
monkeypatch.setattr(status_mod, "_container_status_for",
562-
lambda entry: {"name": entry.name, "status": "stopped", "count": 0})
562+
lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0})
563563
results = status_mod._get_container_status(projects_dir)
564564
names = sorted(r["name"] for r in results)
565565
assert names == ["a", "b"]

0 commit comments

Comments
 (0)