From d5617a8b5822e0e3c54ed327d87a890446c138b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 20:57:51 +0900 Subject: [PATCH] =?UTF-8?q?@=20perf(B04):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=84=B8=EB=B6=80=20=EA=B3=84=EC=82=B0=EC=9D=98=20?= =?UTF-8?q?=ED=97=9B=EC=9D=BC=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20282?= =?UTF-8?q?=E2=86=92154ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB 가 원격(dsm.chemifactory.com)이라 질의 하나가 곧 왕복 12ms 임을 실측. 순차 6건 130ms 중 대부분이 왕복 대기였음. - context: 서로 기다릴 이유가 없는 질의를 두 묶음으로 asyncio.gather (묶음마다 자기 커넥션 — 풀 최대 20 이라 여유). 계획노선 읽기·지표면 샘플러 열기도 같이 보냄. - 격자 산출물(npz 3.1MB): 관을 옮길 때마다 다시 읽던 것을 파일 자국 (수정시각·크기) 열쇠로 재사용. 분석이 다시 돌면 자국이 바뀌어 저절로 새로 읽음. 자체검증 — 용화 프로젝트(관 11·유역 11) 5회 중앙 282→154ms. 같은 입력에 응답 전체가 한 글자도 안 달라짐(JSON 정렬 비교). 전체 테스트 390 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 (1M context) @ --- common_util/common_util_drainage_context.py | 59 ++++++++++++++------- common_util/common_util_drainage_detail.py | 29 +++++++++- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 93ba4771..a9bda0c5 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -27,13 +27,13 @@ from B05_Profile.B05_Profile_Repository import ( get_surface_crs_epsg, ) from B06_Section.B06_Section_Repository import get_longitudinal_section +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_route_geometry import ( RouteVertex, build_route_vertices, load_design_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile -from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler @@ -63,37 +63,58 @@ class DrainageContext: surface_params: dict[str, Any] = field(default_factory=dict) +async def _query(repository_call: Callable[..., Any], *args: Any) -> Any: + """저장소 함수 하나를 **자기 커넥션**으로 실행한다 — 같이 보내려면 커넥션이 갈려야 한다. + + 풀 최대치가 20이라 여기서 서너 개를 동시에 잡아도 여유가 있다(`config_system.DB_POOL_MAX`). + """ + pool = get_db_pool() + async with pool.acquire() as connection: + return await repository_call(connection, *args) + + async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]: """노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유). B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** — B04는 WF1 화면이라 아직 경로가 없다. """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if not stored_path: - return None, "프로젝트 저장 경로가 없습니다." - route = await get_latest_route(connection, project_id) - route_points: list[dict[str, Any]] = [] - longitudinal: dict[str, Any] | None = None - if route: - route_points = await get_route_points(connection, int(route["id"])) - section = await get_longitudinal_section(connection, project_id, int(route["id"])) - longitudinal = (section or {}).get("data") - surface_model_id = (route or {}).get("surface_model_id") - db_epsg = await get_surface_crs_epsg( - connection, project_id, int(surface_model_id) if surface_model_id else 0 + # DB 가 원격이라 질의 하나가 곧 왕복 12ms 다(2026-09-06 실측: 6건 순차 130ms). + # 서로 기다릴 이유가 없는 것끼리 묶어 두 묶음으로 보낸다 — 값은 그대로고 왕복만 겹친다. + stored_path, route, surface_params = await asyncio.gather( + _query(get_project_storage_relative_path, project_id), + _query(get_latest_route, project_id), + _query(get_surface_confirmation_params, str(project_id)), + ) + if not stored_path: + return None, "프로젝트 저장 경로가 없습니다." + + route_points: list[dict[str, Any]] = [] + longitudinal: dict[str, Any] | None = None + surface_model_id = (route or {}).get("surface_model_id") + if route: + route_points, section, db_epsg = await asyncio.gather( + _query(get_route_points, int(route["id"])), + _query(get_longitudinal_section, project_id, int(route["id"])), + _query( + get_surface_crs_epsg, + project_id, + int(surface_model_id) if surface_model_id else 0, + ), ) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) + longitudinal = (section or {}).get("data") + else: + db_epsg = await _query(get_surface_crs_epsg, project_id, 0) project_root = Path(resolve_stored_project_path(stored_path)) # 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로 # 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01). - planned = await asyncio.to_thread(_read_planned_route, project_root, surface_params) + planned, sampler = await asyncio.gather( + asyncio.to_thread(_read_planned_route, project_root, surface_params), + asyncio.to_thread(_open_sampler, project_root, surface_params), + ) if planned is None or len(planned.vertices) < 2: return None, "계획노선을 읽지 못했습니다. B03에서 노선 파일을 확인하세요." - sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params) vertices, z_source = await asyncio.to_thread( resolve_route_profile, planned.vertices, diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 1b9413bb..8e2a2f40 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -69,6 +69,9 @@ from config.config_system import ( logger = logging.getLogger(__name__) +# 마지막으로 읽은 격자 산출물 — {npz 경로: (파일 자국, 읽은 결과)}. `read_road_routing` 참조. +_routing_cache: dict[str, tuple[tuple[tuple[float, int], ...], "RoadRouting"]] = {} + # 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. _SCORE_WEIGHT_STRENGTH = 0.7 _SCORE_WEIGHT_SAG = 0.3 @@ -230,12 +233,21 @@ def build_detail( def read_road_routing(directory: Path) -> RoadRouting | None: - """`03_road_routing` 산출물을 읽는다. 없으면 None.""" + """`03_road_routing` 산출물을 읽는다. 없으면 None. + + 관을 하나 옮길 때마다 같은 파일(3.1MB)을 다시 읽어 45ms 를 썼다(2026-09-06 실측). + 격자는 B04 분석이 다시 돌 때만 바뀌므로 **파일이 그대로면 앞서 읽은 것을 그대로 쓴다** — + 파일 자국(수정시각·크기)이 열쇠라 분석이 다시 돌면 저절로 새로 읽는다. + """ prefix = STAGES["road_routing"] array_path = directory / f"{prefix}_road_routing.npz" if not array_path.exists(): logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) return None + cached = _routing_cache.get(str(array_path)) + stamp = _file_stamp(array_path, directory / f"{prefix}_road_routing.geojson") + if cached is not None and cached[0] == stamp: + return cached[1] try: with np.load(array_path, allow_pickle=False) as data: spec = GridSpec( @@ -266,9 +278,24 @@ def read_road_routing(directory: Path) -> RoadRouting | None: routing.road_cell_index.size, len(routing.base_pipes), ) + # 프로젝트를 오가도 자국이 다르면 새로 읽으므로 한 벌만 들고 있으면 충분하다. + _routing_cache.clear() + _routing_cache[str(array_path)] = (stamp, routing) return routing +def _file_stamp(*paths: Path) -> tuple[tuple[float, int], ...]: + """파일들의 (수정시각, 크기) — 하나라도 바뀌면 값이 달라진다. 없는 파일은 (0, 0).""" + marks = [] + for path in paths: + try: + info = path.stat() + marks.append((info.st_mtime, info.st_size)) + except OSError: + marks.append((0.0, 0)) + return tuple(marks) + + def _read_geometry(path: Path, routing: RoadRouting) -> None: """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" if not path.exists():