diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index bc9b9a2..4275493 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -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 diff --git a/backend/tests/test_preferences_concurrent_write.py b/backend/tests/test_preferences_concurrent_write.py new file mode 100644 index 0000000..15c685e --- /dev/null +++ b/backend/tests/test_preferences_concurrent_write.py @@ -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