* feat(cache): 节点缓存键纳入 CSV 文件签名 ComfyUI 默认只按输入参数判断缓存是否命中。两个节点读的都是外部 CSV, 参数不变而文件被替换时缓存会一直返回旧结果,用户换了数据却看不到变化。 把 CSV 的「路径 + 修改时间 + 大小」作为 IS_CHANGED 的返回值纳入缓存键: 文件一变就重算;文件与参数都不变时指纹稳定,缓存行为与 5.0.0 一致。 路径被防护拒绝时返回稳定哨兵而不抛异常,避免打断 ComfyUI 的缓存判断。 * perf(character): 派生索引随记录缓存,单次执行 62.6ms -> 26.7ms 角色节点每次执行都在 3.4 万条记录上重建 record_map / names / 作品筛选下标, CSV 没变时完全是重复劳动。把三者收进 CharacterIndex,与记录共用同一套 文件签名缓存(路径 + 列 + mtime + size),CSV 一变就重建。 顺带修掉作品下拉菜单的失效问题:菜单改为按默认 CSV 的文件签名缓存, 换 CSV 后自动重建,不再需要重启 ComfyUI。 实测(本机 7 次中位数):generate_characters 62.62ms -> 26.69ms(-57%)。 4 个 seed 下 prompt / characters_json / tested_count 逐字节一致。 剩余约 24ms 在 select_artists 的排除过滤里;改动它会改变 RNG 消耗顺序、 破坏同 seed 的可复现性,故本次不动。 * docs(db): 修正 generation_lock 的持锁范围说明 原文写「调用方应在进入本上下文之前完成抽样、权重计算、提示词拼装等纯 CPU 工作」, 但 RollingArtist 的重试循环必须待在事务里(命中已测组合要重新抽样), 实际持锁时长等于一次完整生成。文档与实现互相矛盾,读代码的人会误判并发行为。 改为如实描述:持锁时长由调用方决定,两种调用方式各自的代价是什么, 以及为什么不能简单把抽样挪到锁外(冲突只会推迟到写入时才被 INSERT OR IGNORE 吞掉)。 同时修正 __init__ 里「不覆盖事务内的业务计算」这句同样有歧义的注释。 纯注释改动,无代码行为变化。 * docs: 5.1.0 变更记录与 README 缓存/性能说明(T-04) * docs: 归档 v5.1.0 迭代计划至 docs/iteration * chore(release): 5.1.0
160 lines
6.2 KiB
Python
160 lines
6.2 KiB
Python
"""节点缓存指纹(``IS_CHANGED``)测试。
|
|
|
|
背景:ComfyUI 默认只按节点输入参数判断缓存是否命中。两个节点读的都是外部 CSV,
|
|
参数不变而文件被替换时,缓存会一直返回旧结果——用户「换了数据却没效果」。
|
|
|
|
这里的断言只覆盖指纹本身(纯文件 IO),不涉及 ComfyUI 运行时;
|
|
运行时是否真的重算由用户在 ComfyUI 中验证。
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from ComfyUI_RollingArtist.ra_core.storage import file_fingerprint
|
|
from ComfyUI_RollingArtist.RollingArtist import RollingArtist
|
|
from ComfyUI_RollingArtist.RollingCharacter import RollingCharacter
|
|
|
|
ARTIST_INPUTS = dict(
|
|
artist_count=3,
|
|
artist_top_count=1,
|
|
artist_top_ratio=0.5,
|
|
artists_prefix=True,
|
|
weight_min=0.2,
|
|
weight_max=1.0,
|
|
weight_total=2.0,
|
|
seed=42,
|
|
use_top_priority=True,
|
|
sort_by_weight=True,
|
|
exclude_artists="",
|
|
force_include="",
|
|
dedup_mode="none",
|
|
max_attempts=10,
|
|
weight_curve="flat",
|
|
tested_db_path="",
|
|
)
|
|
|
|
CHARACTER_INPUTS = dict(
|
|
character_count=2,
|
|
character_top_ratio=0.5,
|
|
character_top_count=1,
|
|
seed=42,
|
|
use_top_priority=True,
|
|
exclude_characters="",
|
|
force_include="",
|
|
copyright_pick="(不限)",
|
|
describe_character=True,
|
|
tested_db_path="",
|
|
)
|
|
|
|
|
|
def _write_csv(path, rows):
|
|
with open(path, "w", encoding="utf-8", newline="") as handle:
|
|
handle.write("name,trigger,count\n")
|
|
for index, name in enumerate(rows):
|
|
handle.write(f"{name},{name},{index + 1}\n")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# file_fingerprint
|
|
# ----------------------------------------------------------------------
|
|
def test_fingerprint_is_stable_without_change(tmp_path):
|
|
csv_path = tmp_path / "a.csv"
|
|
_write_csv(csv_path, ["alpha", "beta"])
|
|
assert file_fingerprint(str(csv_path)) == file_fingerprint(str(csv_path))
|
|
|
|
|
|
def test_fingerprint_changes_when_content_changes(tmp_path):
|
|
csv_path = tmp_path / "a.csv"
|
|
_write_csv(csv_path, ["alpha"])
|
|
before = file_fingerprint(str(csv_path))
|
|
_write_csv(csv_path, ["alpha", "beta", "gamma"])
|
|
assert file_fingerprint(str(csv_path)) != before
|
|
|
|
|
|
def test_fingerprint_changes_when_mtime_changes(tmp_path):
|
|
"""内容长度不变、只改修改时间时也要变(覆盖「同名同大小、内容被换」)。"""
|
|
csv_path = tmp_path / "a.csv"
|
|
_write_csv(csv_path, ["alpha"])
|
|
before = file_fingerprint(str(csv_path))
|
|
stat = os.stat(csv_path)
|
|
os.utime(csv_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
|
|
assert file_fingerprint(str(csv_path)) != before
|
|
|
|
|
|
def test_fingerprint_marks_missing_file(tmp_path):
|
|
missing = tmp_path / "nope.csv"
|
|
value = file_fingerprint(str(missing))
|
|
assert value.startswith("missing:")
|
|
assert file_fingerprint(str(missing)) == value
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# RollingArtist.IS_CHANGED
|
|
# ----------------------------------------------------------------------
|
|
def test_artist_is_changed_is_stable(tmp_path):
|
|
csv_path = tmp_path / "artists.csv"
|
|
_write_csv(csv_path, ["alpha", "beta"])
|
|
first = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=str(csv_path))
|
|
second = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=str(csv_path))
|
|
assert first == second, "文件与参数都没变时指纹必须稳定,否则每次执行都会重算"
|
|
|
|
|
|
def test_artist_is_changed_tracks_csv_replacement(tmp_path):
|
|
csv_path = tmp_path / "artists.csv"
|
|
_write_csv(csv_path, ["alpha"])
|
|
before = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=str(csv_path))
|
|
_write_csv(csv_path, ["gamma", "delta"])
|
|
after = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=str(csv_path))
|
|
assert before != after, "CSV 被替换后指纹必须变化,否则会命中旧缓存"
|
|
|
|
|
|
def test_artist_is_changed_returns_str(tmp_path):
|
|
"""ComfyUI 会把返回值放进缓存键,必须是可哈希且可比较的稳定值。"""
|
|
csv_path = tmp_path / "artists.csv"
|
|
_write_csv(csv_path, ["alpha"])
|
|
value = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=str(csv_path))
|
|
assert isinstance(value, str)
|
|
assert hash(value) == hash(str(value))
|
|
|
|
|
|
def test_artist_is_changed_survives_rejected_path(tmp_path):
|
|
"""路径被防护拒绝时不能抛异常,否则会打断 ComfyUI 的缓存判断。"""
|
|
protected = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "win.ini")
|
|
value = RollingArtist.IS_CHANGED(**ARTIST_INPUTS, custom_csv_path=protected)
|
|
assert isinstance(value, str)
|
|
assert value.startswith("invalid:")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# RollingCharacter.IS_CHANGED
|
|
# ----------------------------------------------------------------------
|
|
def test_character_is_changed_is_stable(tmp_path):
|
|
csv_path = tmp_path / "characters.csv"
|
|
_write_csv(csv_path, ["alpha", "beta"])
|
|
first = RollingCharacter.IS_CHANGED(**CHARACTER_INPUTS, custom_csv_path=str(csv_path))
|
|
second = RollingCharacter.IS_CHANGED(**CHARACTER_INPUTS, custom_csv_path=str(csv_path))
|
|
assert first == second
|
|
|
|
|
|
def test_character_is_changed_tracks_csv_replacement(tmp_path):
|
|
csv_path = tmp_path / "characters.csv"
|
|
_write_csv(csv_path, ["alpha"])
|
|
before = RollingCharacter.IS_CHANGED(**CHARACTER_INPUTS, custom_csv_path=str(csv_path))
|
|
_write_csv(csv_path, ["gamma", "delta"])
|
|
after = RollingCharacter.IS_CHANGED(**CHARACTER_INPUTS, custom_csv_path=str(csv_path))
|
|
assert before != after
|
|
|
|
|
|
def test_character_is_changed_default_path_is_resolved():
|
|
"""留空走默认 CSV:指纹应指向节点目录下的默认文件,而不是空串。"""
|
|
value = RollingCharacter.IS_CHANGED(**CHARACTER_INPUTS)
|
|
assert value.startswith("missing:") or value.count("|") == 2
|
|
assert "danbooru_character_001.csv" in value
|
|
|
|
|
|
@pytest.mark.parametrize("node", [RollingArtist, RollingCharacter])
|
|
def test_is_changed_accepts_unknown_inputs(node):
|
|
"""ComfyUI 会把节点全部输入当关键字传进来,签名必须能吞掉未知键。"""
|
|
value = node.IS_CHANGED(custom_csv_path="", future_input="ignored")
|
|
assert isinstance(value, str)
|