API loader shares one module object per file (issue #132, BUG-1482)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 8m49s

_load_local_module always re-executed the file as _jyotish_local_<name>, so
engine, dasha, varga and others ran as extra copies with their own caches.
It now reuses scripts.<name> / <name> when that file is already imported,
else imports it under its plain name so later imports share it; the private
copy remains only as a fallback. Call sites unchanged. Probe after one
consultation and one chart: loader-made copies 19 -> 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 12:43:59 +08:00
co-authored by Claude Opus 5.5
parent 0594afcd5c
commit 8a72aa5b44
4 changed files with 73 additions and 0 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 排盘服务里同一份代码只留一份
- 排盘服务内部不再给同一个计算文件另起私有副本(以前一次咨询加一次排盘后有 19 份),各处共用同一份和它的缓存,省内存、少重复计算。结果不变。
- Skill 版本不变(BUG-1482,issue #132)。
## 2026-10-28 — 收起第一批没人用的台账脚本
- 删掉 11 个只被自己测试用到的 closure 类台账脚本和它们的测试;它们以前生成的数据文件保留。功能和结果都不变。
+15
View File
@@ -20232,3 +20232,18 @@
- 关联:体检第 16 条(python-hygiene 数量护栏)。
- 复发自:无
- 修复版本:分支 `codex/frontend-dead-files-20261028`(与 #139 同一分支,两个独立提交)。
## BUG-1482 | 排盘服务的加载器给同一个 Python 文件另起一份私有副本,缓存互不相通
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 20 条 → #132)
- 影响面:`scripts/jyotish_api_server.py` `_load_local_module`(97 处调用点不动)。
- 现象:一次咨询加一次排盘之后,同一个文件在进程里有多份:引擎、大运、Narayana、分盘等 19 个文件由加载器以 `_jyotish_local_<名>` 另起一份,又被别处以 `scripts.<名>` 或 `<名>` 正常 import 一份(`dasha_analyzer`、`jaimini`、`jyotish_engine`、`narayana_dasha`、`varga`、`domain_calculation_service` 各 3 份)。每份都有自己的模块级缓存,彼此不相通,多占内存、多算一遍。
- 触发条件:任何一次经由加载器的计算。
- 根因:加载器总是用 `spec_from_file_location` 以私有名字重新执行文件,不看 `sys.modules` 里是否已经有同一个文件。
- 修复:加载器先查 `scripts.<名>`、`<名>` 是否已载入同一文件,有就复用;没有就按普通名字 `import`(脚本目录本来就在 `sys.path` 上),之后别处的普通 import 直接拿到同一份;只有普通名字解析到别的文件时才退回旧的私有副本。只改加载器,不动调用点。普通名字还能被子进程按名字找到(私有名字不能),为 #133 多进程打底。
- 验证:同一段「一次咨询 + 一次排盘」探针:重复文件 32 → 19、多出的副本 38 → 19,加载器造出的私有副本 19 → 0。剩下的 19 份是 `scripts.X` 与 `X` 两种普通 import 并存,与加载器无关,不在本单范围。新测试 `tests/test_local_module_reuse_20261028.py`。凡引用加载器 / 计算服务的 41 个测试文件,改前改后各跑一遍,失败清单一致(都只有 `test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow`,staging 上本来就失败)。`test_api_server_growth_contract.py` 通过。
- 防复发:上述测试断言加载器不再产生 `_jyotish_local_` 副本、与普通 import 拿到同一个模块对象。
- 关联:#133(重计算多进程)。
- 复发自:无
- 修复版本:分支 `codex/local-module-reuse-20261028`。
+14
View File
@@ -2536,6 +2536,20 @@ def _load_local_module(module_name):
return cached
relative = module_name.replace('.', os.sep)
module_path = os.path.join(SCRIPTS_DIR, f'{relative}.py')
# issue #132: one copy per file. Reuse what `import scripts.X` / `import X`
# already loaded, else import it under its plain name so later imports
# share it; a private `_jyotish_local_X` copy kept its own caches.
for loaded_name in (f'scripts.{module_name}', module_name):
loaded = sys.modules.get(loaded_name)
loaded_file = getattr(loaded, '__file__', None)
if loaded_file and os.path.realpath(loaded_file) == os.path.realpath(module_path):
_LOCAL_MODULE_CACHE[module_name] = loaded
return loaded
if os.path.isfile(module_path) and SCRIPTS_DIR in sys.path:
module = importlib.import_module(module_name)
if os.path.realpath(getattr(module, '__file__', '') or '') == os.path.realpath(module_path):
_LOCAL_MODULE_CACHE[module_name] = module
return module
spec = importlib.util.spec_from_file_location(f'_jyotish_local_{module_name}', module_path)
if not spec or not spec.loader:
raise ImportError(f'Cannot load local module: {module_name}')
+39
View File
@@ -0,0 +1,39 @@
"""Issue #132: the API server's loader shares one module object per file."""
from __future__ import annotations
import sys
import scripts.jyotish_api_server as server
def test_the_loader_reuses_a_module_that_is_already_imported():
import scripts.dasha_common as dasha_common
server._LOCAL_MODULE_CACHE.pop("dasha_common", None)
assert server._load_local_module("dasha_common") is dasha_common
def test_a_fresh_load_is_the_plain_module_later_imports_get():
for name in ("gulika", "scripts.gulika", "_jyotish_local_gulika"):
sys.modules.pop(name, None)
server._LOCAL_MODULE_CACHE.pop("gulika", None)
loaded = server._load_local_module("gulika")
import gulika
assert loaded is gulika
assert "_jyotish_local_gulika" not in sys.modules
def test_no_private_copies_after_a_consultation_and_a_chart():
service = server.JyotishComputeService()
body = {"year": 1990, "month": 3, "day": 14, "hour": 9, "minute": 30, "lat": 31.23, "lon": 121.47, "tz": 8,
"ayanamsa": "lahiri", "question": "事业如何", "theme": ["career"], "skip_vedastro_main_entry_overview": True,
"defer_optional_external_evidence": True}
for compute in (service._compute_consultation_workflow, service._compute_chart):
try:
compute(dict(body))
except Exception:
pass # the module graph is what is measured, not the payload
private = sorted(name for name in sys.modules if name.startswith("_jyotish_local_"))
assert private == [], private