Consult card keeps parsed packets in memory, prewarms each turn, writes 0600 (issue #129, BUG-1476)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 11s

- consult_packet_view keeps the last 3 parsed packets with their shared domain
  base (~30 MB each). A hit reads no file and copies nothing: attach for one
  domain 0.106 s -> 0.002 s. Memory holds the file's JSON round trip, i.e.
  exactly what a hit returned before; consult_card_facts sha256 equal old/new.
- Cache files are created 0600, same bytes as before.
- The consult route fires a warm for the turn's chart before reserving credit,
  so the day's first question does not build the packet on the answer path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 11:57:53 +08:00
co-authored by Claude Opus 5.5
parent a81b32349f
commit 18c6e13028
8 changed files with 300 additions and 8 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 咨询准备数据更快,含出生资料的缓存文件只有本机服务能读
- 同一张盘连着问时,每轮准备数据卡从约 0.1 秒降到几毫秒;每天第一问不再在回答路径上现算整包,问题一发出去就在后台提前算好。回答内容不变。
- 含出生资料的缓存文件改为只有服务本身能读(0600)。
- Skill 版本不变(BUG-1476,issue #129)。
## 2026-10-28 — 每个页面第一次打开再少下载约 25~30 KB
- 首页、星盘页、档案页、报告页、星历页第一次打开再少下载约 25~30 KB(压缩后):日期解析改用自带的小函数,「退出」和「分享」用的代码等到用时才下载。
+15
View File
@@ -20141,3 +20141,18 @@
- 关联:BUG-1474(#124 首页懒加载校正界面)、BUG-1130。
- 复发自:无
- 修复版本:分支 `codex/home-first-screen-deps-20261028`。
## BUG-1476 | 咨询数据卡命中缓存也要每轮读 5.5 MB 文件再整份复制;每天首问现算;缓存文件谁都能读
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 6 条 → #129)
- 影响面:咨询每一轮的数据卡(`scripts/consult_full_data_cache.py` `attach_consult_full_data` / `warm_consult_packet`);同一缓存目录下报告参考、合盘缓存文件的权限;`frontend/src/app/api/consult/route.ts`、`frontend/src/lib/consult-packet-warm.ts`。
- 现象:① 命中缓存时每个领域每轮仍要读 5.8 MB JSON、再深拷贝共享部分,本机实测每轮约 0.11 秒;② 缓存键带服务器日期,每天第一问要在回答路径上现算整包(本机实测约 5.8 秒);③ 缓存文件权限 0644,里面有出生资料,不合 0600 规矩。
- 触发条件:任何带数据卡的咨询轮。
- 根因:缓存只有文件一层,命中也要整份解析;BUG-1471 已把每轮的深拷贝减到一次,但每轮仍有一次;预热只在保存星盘时做,跨天就失效;写文件用的是默认权限。
- 修复:新增 `consult_packet_view`,进程内按缓存键留最近 3 份「已解析的包 + 它的共享底稿」(每份约 30 MB,合计约 90 MB),数据卡和预热都走它,命中时不读文件、不复制,只读共享对象;首次构建的这一轮仍用构建出的对象作答,内存里记的是文件读回的那一份(和以前「命中」读到的完全一样)。报告参考、合盘等其他用 `get_or_build` 的地方不走内存,仍各拿一份。缓存文件改为创建即 0600,内容字节不变(不排序键)。咨询开始时(扣点前、确定了这一轮用哪张盘之后)不等待地发一次预热,按同一缓存键在后台把今天的包建好;引擎里同键的构建只跑一次,数据卡来要时直接接上。
- 验证:本机真包实测,命中时整段 `attach_consult_full_data`:1 个领域 0.106 → 0.0018 秒,2 个领域 0.105 → 0.0041 秒(每领域约 0.002 秒,单子要求 ≤0.05)。改前 / 改后代码对同一张虚构盘的全部领域,首次构建与之后命中两轮的 `consult_card_facts`(去掉耗时字段)sha256 逐个相同。新测试 `tests/test_consult_card_cache_memory_20261028.py`(命中逐字节相同且不读文件;读事实不改动共享包和底稿;只留最近 3 份;新文件 0600 且字节与以前一致;首次构建作答用构建对象、内存记文件读回的那份);`frontend/tests/consult-packet-warm-20261009.test.ts` 新增一条(预热请求体与数据卡同一身份、不等待、引擎出错不影响这一轮)。既有 `tests/test_consult_card_full_source.py` 改前改后都全过。Python 快速门全部 Python 步骤通过(pytest 370 s);前端全量测试与 staging 逐条比对无新增失败(`consultation-workflow-contract.test.ts` 一处断言锁着 `beforeReserve` 只解构 `consultationMode`,已改认新参数);tsc 0、lint 0。
- 防复发:上述测试。读多写少的大包留在内存只读共享,不要每轮重读重拷;含出生资料的文件一律创建即 0600。
- 关联:BUG-1471(#122 每份报告只拷一次)、BUG-1305(保存星盘后的预热)。
- 复发自:无
- 修复版本:分支 `codex/consult-card-cache-memory-20261028`。
+3 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import { prewarmConsultPacketForTurn } from "@/lib/consult-packet-warm";
import {
consultationInputSchema,
consultationWorkflowReceipt,
@@ -569,7 +570,8 @@ export async function POST(request: Request) {
return data;
},
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
beforeReserve: async ({ consultationMode }) => {
beforeReserve: async ({ consultationMode, serverChart }) => {
if (serverChart && consultationMode !== "declared_birth_window") prewarmConsultPacketForTurn(serverChart.toolInput);
prepPhases.push({
phase: "prep.subject",
durationMs: Math.max(0, Date.now() - prepareStarted),
+51
View File
@@ -70,3 +70,54 @@ export function scheduleConsultPacketWarm(
}
return true;
}
type ConsultTurnBirth = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
lat: number;
lon: number;
tz: number;
ayanamsa?: string;
};
/**
* At the start of a consult turn, ask the engine to build (or load) the same
* packet the card will read. The cache key carries the server's date, so the
* day's first question otherwise built it on the critical path (issue #129).
* Fire and forget: the turn never waits on it, and the engine joins a running
* build when the card asks for the same key.
*/
export function prewarmConsultPacketForTurn(
birth: ConsultTurnBirth,
deps: Pick<WarmDeps, "fetch" | "apiBase"> = {
fetch,
apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
},
): void {
const body = {
year: birth.year,
month: birth.month,
day: birth.day,
hour: birth.hour,
minute: birth.minute,
second: 0,
lat: birth.lat,
lon: birth.lon,
tz: birth.tz,
ayanamsa: birth.ayanamsa || "lahiri",
node_mode: "mean",
consult_card_warm_only: true,
};
try {
void deps.fetch(`${deps.apiBase ?? "http://127.0.0.1:5200"}/api/consultation_workflow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).catch(() => undefined);
} catch {
// A synchronous fetch failure must not touch the turn.
}
}
@@ -2,7 +2,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { consultWarmBodyFromColumns, scheduleConsultPacketWarm } from "../src/lib/consult-packet-warm.ts";
import { readFileSync } from "node:fs";
import { consultWarmBodyFromColumns, prewarmConsultPacketForTurn, scheduleConsultPacketWarm } from "../src/lib/consult-packet-warm.ts";
const columns = {
birth_date: "1901-01-01",
@@ -49,3 +51,31 @@ test("a warm that cannot be scheduled does not break the save (BUG-1305)", () =>
assert.equal(scheduled, false);
assert.equal(fetched, false);
});
test("a consult turn prewarms the card packet with the same identity the card reads (issue #129)", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
prewarmConsultPacketForTurn(
{ year: 1990, month: 3, day: 14, hour: 9, minute: 30, lat: 31.23, lon: 121.47, tz: 8, ayanamsa: "raman" },
{
apiBase: "http://engine.test",
fetch: (async (url: string, init: RequestInit) => {
calls.push({ url, body: JSON.parse(String(init.body)) });
return new Response("{}");
}) as unknown as typeof fetch,
},
);
assert.equal(calls.length, 1, "sent without waiting");
assert.equal(calls[0].url, "http://engine.test/api/consultation_workflow");
assert.deepEqual(calls[0].body, {
year: 1990, month: 3, day: 14, hour: 9, minute: 30, second: 0,
lat: 31.23, lon: 121.47, tz: 8, ayanamsa: "raman", node_mode: "mean",
consult_card_warm_only: true,
});
// A failing engine never reaches the turn.
prewarmConsultPacketForTurn(
{ year: 1990, month: 3, day: 14, hour: 9, minute: 30, lat: 31.23, lon: 121.47, tz: 8 },
{ apiBase: "http://engine.test", fetch: (async () => { throw new Error("down"); }) as unknown as typeof fetch },
);
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /beforeReserve: async \(\{ consultationMode, serverChart \}\) => \{\n\s+if \(serverChart && consultationMode !== "declared_birth_window"\) prewarmConsultPacketForTurn\(serverChart\.toolInput\);/);
});
@@ -165,7 +165,9 @@ test("consultation plans are server-owned and bounded", () => {
assert.ok(beforeReserveAt >= 0 && reserveAt > beforeReserveAt);
assert.match(
prepareCall.slice(beforeReserveAt, reserveAt),
/beforeReserve:\s*async\s*\(\{\s*consultationMode\s*\}\)[\s\S]*createConsultationPlan\(\{/,
// 原值:参数只解构 consultationMode;新值:还解构 serverChart,用来在扣点前不等待地预热数据卡
// 原因:issue #129 每天首问不在回答路径上现算整包;createConsultationPlan 仍在这个回调里、仍早于 reserve
/beforeReserve:\s*async\s*\(\{\s*consultationMode,\s*serverChart\s*\}\)[\s\S]*createConsultationPlan\(\{/,
);
assert.match(
prepareCall.slice(reserveAt),
+72 -5
View File
@@ -2,16 +2,19 @@
The key is chart identity + ayanamsa + node mode + engine version
+ the reference date used for current dasha, transits and the year chart.
The file name is a hash. Birth data stays inside the scratch file.
Files older than two days are removed.
The file name is a hash. Birth data stays inside the scratch file, mode 0600.
Files older than two days are removed. The consult card also keeps the last
few parsed packets in memory with their shared domain base (issue #129).
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from collections import OrderedDict
from datetime import date
from pathlib import Path
from types import SimpleNamespace
@@ -24,6 +27,13 @@ KEEP_SECONDS = 2 * 24 * 60 * 60
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
# Parsed consult packets kept in this process, newest last. One entry is about
# 18 MB parsed plus its shared base copy (~30 MB together), so three stay well
# inside the 4 GB host. A hit skips the 5.5 MB file read and the base copy.
MEMORY_SLOTS = 3
_memory: "OrderedDict[str, tuple[dict[str, Any], dict[str, Any]]]" = OrderedDict()
_memory_guard = threading.Lock()
# One build per cache key. A later caller waits and reads the stored packet.
class _Flight:
def __init__(self) -> None:
@@ -137,6 +147,17 @@ def prune_cache(now: float | None = None, *, name: str = "consult_full_data_cach
return removed
def _write_private_text(path: Path, text: str) -> None:
"""Birth data: the file is created 0600, never readable by other users.
Same bytes as before (no key sorting), so a packet read back is unchanged.
"""
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(text)
os.chmod(path, 0o600)
def _read_cached(path: Path, started: float, now: Callable[[], float]) -> dict[str, Any]:
packet = json.loads(path.read_text(encoding="utf-8"))
return {"packet": packet, "hit": True, "elapsed_s": now() - started, "slow": False}
@@ -186,7 +207,7 @@ def get_or_build(
dump_kwargs: dict[str, Any] = {"ensure_ascii": False, "separators": (",", ":")}
if json_default is not None:
dump_kwargs["default"] = json_default
temporary.write_text(json.dumps(packet, **dump_kwargs), encoding="utf-8")
_write_private_text(temporary, json.dumps(packet, **dump_kwargs))
temporary.replace(path)
elapsed = now() - started
return {
@@ -205,6 +226,51 @@ def get_or_build(
_flights.pop(key, None)
def consult_packet_view(identity: dict[str, Any], builder: PacketBuilder | None = None) -> dict[str, Any]:
"""The consult card's packet and its shared domain base, both read only.
Kept in memory for the last ``MEMORY_SLOTS`` charts. Callers must not
mutate ``packet`` or ``base``: later turns read the same objects. Other
users of ``get_or_build`` (report reference, marriage match) still get
their own copy from the file.
"""
try:
from pl9_full_data_export import full_data_domain_base
except ImportError: # pragma: no cover - package import path
from scripts.pl9_full_data_export import full_data_domain_base
key = cache_key(identity)
started = time.perf_counter()
with _memory_guard:
entry = _memory.get(key)
if entry is not None:
_memory.move_to_end(key)
if entry is not None:
return {"packet": entry[0], "base": entry[1], "hit": True, "memory": True,
"elapsed_s": time.perf_counter() - started, "slow": False}
cached = get_or_build(identity, builder)
base = full_data_domain_base(cached["packet"])
# Remember what a file hit returns: a fresh build is the builder's own
# object, and its JSON round trip (lists for tuples, string keys) is what
# every later turn used to read. This turn still answers from the build.
remembered = (cached["packet"], base)
if not cached["hit"]:
path = _cache_path(identity)
if path.is_file():
parsed = json.loads(path.read_text(encoding="utf-8"))
remembered = (parsed, full_data_domain_base(parsed))
with _memory_guard:
_memory[key] = remembered
_memory.move_to_end(key)
while len(_memory) > MEMORY_SLOTS:
_memory.popitem(last=False)
return {**cached, "base": base, "memory": False}
def clear_memory_cache() -> None:
with _memory_guard:
_memory.clear()
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
"""Copy the engine's whole-sign aspects onto the consult packet. Do not recompute."""
modules = reading.get("modules") if isinstance(reading, dict) else None
@@ -274,7 +340,7 @@ def warm_consult_packet(body: dict[str, Any] | None) -> dict[str, Any]:
}
try:
try:
cached = get_or_build(identity_from_body(dict(body or {})))
cached = consult_packet_view(identity_from_body(dict(body or {})))
except Exception as exc:
return {
"success": False,
@@ -317,12 +383,13 @@ def attach_consult_full_data(result: dict[str, Any], body: dict[str, Any] | None
from scripts.consult_card_domain_facts import build_consult_card_facts
try:
identity = identity_from_body(payload)
cached = get_or_build(identity)
cached = consult_packet_view(identity)
facts = build_consult_card_facts(
cached["packet"],
_requested_domains(payload),
minute_confirmed=None,
body=payload,
base=cached["base"],
)
facts["timing"] = {
"hit": bool(cached["hit"]),
@@ -0,0 +1,119 @@
"""Issue #129: the consult card keeps parsed packets in memory, files are 0600."""
from __future__ import annotations
import copy
import hashlib
import json
import stat
import uuid
from pathlib import Path
from consult_card_domain_facts import build_consult_card_facts
import consult_full_data_cache as cache
ROOT = Path(__file__).resolve().parents[1]
DENSITY = ROOT / "frontend" / "tests" / "fixtures" / "report-density-fictional-engine.json"
DOMAINS = ["career", "wealth", "marriage", "health"]
def _identity(minute: int = 1) -> dict:
return {
"schema": cache.CACHE_SCHEMA,
"year": 1901, "month": 1, "day": 1, "hour": 0, "minute": minute, "second": 0,
"lat": 0, "lon": 0, "tz": 0, "ayanamsa": "raman", "node_mode": "mean",
"engine_version": f"test-{uuid.uuid4().hex}",
"reference_date": "2026-10-28",
}
def _builder(_identity: dict) -> dict:
return json.loads(DENSITY.read_text(encoding="utf-8"))
def _fingerprint(value) -> str:
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
def _facts_the_old_way(identity: dict) -> str:
# Before #129: every turn parsed the file and built facts without a shared base.
packet = json.loads(cache._cache_path(identity).read_text(encoding="utf-8"))
return json.dumps(build_consult_card_facts(packet, DOMAINS, minute_confirmed=True), ensure_ascii=False)
def test_memory_hits_give_byte_identical_facts_and_skip_the_file():
cache.clear_memory_cache()
identity = _identity()
first = cache.consult_packet_view(identity, _builder)
assert first["hit"] is False and first["memory"] is False
expected = _facts_the_old_way(identity)
from_build = build_consult_card_facts(first["packet"], DOMAINS, minute_confirmed=True, base=first["base"])
assert json.dumps(from_build, ensure_ascii=False) == expected
# The file is no longer read on a memory hit.
path = cache._cache_path(identity)
saved = path.read_bytes()
path.unlink()
try:
second = cache.consult_packet_view(identity, _builder)
third = cache.consult_packet_view(identity, _builder)
finally:
path.write_bytes(saved)
assert second["hit"] is True and second["memory"] is True
assert second["packet"] is third["packet"] and second["base"] is third["base"]
for view in (second, third):
facts = build_consult_card_facts(view["packet"], DOMAINS, minute_confirmed=True, base=view["base"])
assert json.dumps(facts, ensure_ascii=False) == expected
def test_reading_facts_never_changes_the_shared_packet_or_base():
cache.clear_memory_cache()
identity = _identity()
cache.consult_packet_view(identity, _builder)
view = cache.consult_packet_view(identity, _builder)
before = (_fingerprint(view["packet"]), _fingerprint(view["base"]))
for _ in range(3):
facts = build_consult_card_facts(view["packet"], DOMAINS, minute_confirmed=False, base=view["base"])
# A caller that edits its facts must not reach the shared objects.
for row in facts["domains"]:
row.clear()
assert (_fingerprint(view["packet"]), _fingerprint(view["base"])) == before
def test_memory_keeps_the_newest_charts_only():
cache.clear_memory_cache()
identities = [_identity(minute) for minute in range(1, cache.MEMORY_SLOTS + 2)]
for identity in identities:
cache.consult_packet_view(identity, _builder)
assert len(cache._memory) == cache.MEMORY_SLOTS
assert cache.cache_key(identities[0]) not in cache._memory
assert cache.cache_key(identities[-1]) in cache._memory
def test_new_cache_files_are_private():
identity = _identity()
cache.get_or_build(identity, _builder)
mode = stat.S_IMODE(cache._cache_path(identity).stat().st_mode)
assert mode == 0o600, oct(mode)
# Same bytes as before: compact, key order as built (not sorted).
expected = json.dumps(_builder(identity), ensure_ascii=False, separators=(",", ":"))
assert cache._cache_path(identity).read_text(encoding="utf-8") == expected
def test_a_miss_answers_from_the_build_and_remembers_the_file_copy():
cache.clear_memory_cache()
built = {"value": None}
def builder(identity: dict) -> dict:
packet = _builder(identity)
packet["worksheets"]["tuple_probe"] = ("a", "b")
built["value"] = packet
return packet
identity = _identity()
first = cache.consult_packet_view(identity, builder)
assert first["packet"] is built["value"]
remembered = cache.consult_packet_view(identity, builder)["packet"]
assert remembered is not built["value"]
assert remembered["worksheets"]["tuple_probe"] == ["a", "b"], "what a file hit always returned"
assert copy.deepcopy(remembered) == json.loads(cache._cache_path(identity).read_text(encoding="utf-8"))