fix(preferences): 轮询间隔写入走 save(), 不再覆盖并发写入的其它偏好

在设置里拖动「行情轮询间隔」的同时切换另一个开关(比如实时行情总开关),
刷新页面后那个开关又变回原样 —— 它被间隔的写盘整体覆盖掉了。

preferences.save 的 docstring 记着这个坑: "FastAPI 同步端点跑线程池,
并行 PUT 各自基于旧快照写盘会互相覆盖 (实测: 压缩总开关并行写分时/日K
两键, 后写者把先写者覆盖)", 所以它的 read-modify-write 整段在 _SAVE_LOCK
里。set_realtime_quote_interval 是唯一没跟上的 setter: 它自己 load() 拿快照,
再 _path().write_text() 全量写回, 全程不持锁。两个 PUT 在飞时, 后写的那份
旧快照会把先写的键抹掉。

改成 save({"realtime_quote_interval": interval}) —— 与本模块其余全部 setter
(set_sentiment_exclude_st / set_pipeline_pull_types / set_review_schedule ...)
一致, 返回值与缓存失效行为不变。
This commit is contained in:
kevin9327
2026-09-10 08:13:24 +09:00
parent d0a14b5c1b
commit 0da6aea52a
2 changed files with 110 additions and 7 deletions
+6 -7
View File
@@ -88,13 +88,12 @@ def get_realtime_quote_interval() -> float:
def set_realtime_quote_interval(interval: float) -> float:
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
current = load()
current["realtime_quote_interval"] = interval
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。
走 save() 而不是自己 load + write_text: 锁外的 read-modify-write 会用旧快照
整体覆盖文件, 把并发写入的另一个偏好丢掉 (见 save 的 docstring)。
"""
save({"realtime_quote_interval": interval})
return interval
@@ -0,0 +1,104 @@
"""并发写 preferences.json 不得互相覆盖。
preferences.save 的 docstring 记着这个坑: "FastAPI 同步端点跑线程池, 并行 PUT
各自基于旧快照写盘会互相覆盖", 所以 save 的 read-modify-write 整段在 _SAVE_LOCK
里。set_realtime_quote_interval 是唯一一个绕开该锁、自己 load + write_text 的
setter —— PUT /api/settings/preferences/quote-interval 与任意另一个偏好 PUT
同时在飞时, 后者会被前者用旧快照整体覆盖掉。
"""
from __future__ import annotations
import json
import threading
import pytest
from app.services import preferences
_OTHER_KEY = "realtime_quotes_enabled"
@pytest.fixture
def prefs_path(tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
preferences._invalidate_cache()
yield path
preferences._invalidate_cache()
def _read(path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def test_interval_setter_does_not_clobber_a_concurrent_save(prefs_path, monkeypatch):
"""轮询间隔写入与另一个偏好写入并发时, 两个键都要留下。
用一个会在第一次调用时挂起的 load 替身制造交错: 间隔 setter 拿到快照后
停住, 另一个 save 完整跑完, 然后间隔 setter 继续写盘。
"""
preferences.save({_OTHER_KEY: False})
first_load_entered = threading.Event()
release_first_load = threading.Event()
other_save_done = threading.Event()
load_calls = []
real_load = preferences.load
def _load_pausing_on_first_call() -> dict:
snapshot = real_load()
load_calls.append(1)
if len(load_calls) == 1:
first_load_entered.set()
release_first_load.wait(10)
return snapshot
monkeypatch.setattr(preferences, "load", _load_pausing_on_first_call)
def _set_interval() -> None:
preferences.set_realtime_quote_interval(9.0)
def _save_other() -> None:
preferences.save({_OTHER_KEY: True})
other_save_done.set()
interval_thread = threading.Thread(target=_set_interval, name="set-interval")
interval_thread.start()
assert first_load_entered.wait(10), "间隔 setter 没有进入 load"
other_thread = threading.Thread(target=_save_other, name="save-other")
other_thread.start()
# 有锁时另一个 save 会一直等到间隔 setter 写完 (这里超时是预期的);
# 无锁时它会立刻写完, 随后被间隔 setter 的旧快照覆盖。
other_save_done.wait(0.5)
release_first_load.set()
interval_thread.join(10)
other_thread.join(10)
assert not interval_thread.is_alive() and not other_thread.is_alive()
monkeypatch.setattr(preferences, "load", real_load)
preferences._invalidate_cache()
stored = _read(prefs_path)
assert stored["realtime_quote_interval"] == 9.0
assert stored[_OTHER_KEY] is True, "并发的偏好写入被间隔 setter 的旧快照覆盖了"
def test_interval_setter_keeps_existing_keys(prefs_path):
"""顺序场景: 写间隔不能丢掉文件里已有的其它偏好。"""
preferences.save({_OTHER_KEY: True, "minute_sync_enabled": True})
preferences.set_realtime_quote_interval(3.0)
stored = _read(prefs_path)
assert stored == {
_OTHER_KEY: True,
"minute_sync_enabled": True,
"realtime_quote_interval": 3.0,
}
def test_interval_setter_returns_value_and_refreshes_cache(prefs_path):
"""返回值与缓存失效行为保持不变。"""
assert preferences.set_realtime_quote_interval(12.5) == 12.5
assert preferences.get_realtime_quote_interval() == 12.5