Files
codex-app-server/cloud/tests/test_rate_limit_db.py
host 8bda29eac3 fix: 限流清理移出请求路径,根治 rate_limit_hits 死锁
allow() 原先每请求在同一事务里 DELETE 旧行 + INSERT + COUNT,
控制台并发加载 + 双 server 下两个事务在 rate_limit_hits 表上
死锁,未捕获异常直接断连、nginx 报 502(2026-10-27 实测,
环境与链路两区同时 502 均源于此,与各自端点逻辑无关)。

结构修复:
- allow() 只做 INSERT + 窗口内 COUNT(COUNT 按 ts > now-window
  过滤,过期行残留不影响计数正确性);建表/索引每实例只做一次。
- 过期行改由后台线程 sweep_rate_limit_hits() 每 60 秒清一次;
  Postgres 下拿事务级 advisory lock,另一台 server 正在清时
  直接跳过,清理者之间也不死锁;清理失败只记 warning。
- main() 里对 DbRateLimiter 起 sweeper 线程(内存版不需要)。

单测:allow 不再删旧行、窗口计数忽略过期行、sweep 只删过期
行、空库 sweep 不炸。
2026-10-27 05:40:30 +08:00

135 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HA 阶段 2 单测:DB 共享限流。
DbRateLimiter 与内存版同接口:窗口内达到阈值即 False、窗口
滑过去后恢复、不同 key 互不干扰;关键一条是多实例共享计数
(两个 server 进程各建一个实例,计数仍合流——多 server 限流
的前提)。SQLite 跑同款 SQL,Postgres 方言差异由 db.py 适配层
覆盖(本文件不连真实 Postgres)。
"""
import os
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.server import (DbRateLimiter, RateLimiter,
sweep_rate_limit_hits)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.server import (DbRateLimiter, RateLimiter,
sweep_rate_limit_hits)
class DbRateLimiterTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.now = [1000.0]
def tearDown(self):
self.tmp.cleanup()
def _limiter(self, per_minute=3, window=60):
return DbRateLimiter(self.db_path, per_minute=per_minute,
window_seconds=window,
time_fn=lambda: self.now[0])
def test_allow_up_to_limit_then_false(self):
lim = self._limiter()
self.assertTrue(lim.allow("k1"))
self.assertTrue(lim.allow("k1"))
self.assertTrue(lim.allow("k1"))
self.assertFalse(lim.allow("k1"))
def test_window_slides_and_recovers(self):
lim = self._limiter()
for _ in range(3):
self.assertTrue(lim.allow("k1"))
self.assertFalse(lim.allow("k1"))
self.now[0] += 61
self.assertTrue(lim.allow("k1"))
def test_keys_are_independent(self):
lim = self._limiter()
for _ in range(3):
self.assertTrue(lim.allow("a"))
self.assertFalse(lim.allow("a"))
self.assertTrue(lim.allow("b"))
def test_two_instances_share_counts(self):
# 多 server 的核心性质:两个实例(两个进程)计数合流。
a = self._limiter()
b = self._limiter()
self.assertTrue(a.allow("shared"))
self.assertTrue(a.allow("shared"))
self.assertTrue(b.allow("shared"))
self.assertFalse(b.allow("shared"))
self.assertFalse(a.allow("shared"))
def test_memory_limiter_unchanged(self):
lim = RateLimiter(per_minute=2, window_seconds=60,
time_fn=lambda: self.now[0])
self.assertTrue(lim.allow("k"))
self.assertTrue(lim.allow("k"))
self.assertFalse(lim.allow("k"))
def _rows(self):
conn = sqlite3.connect(self.db_path)
try:
return sorted(conn.execute(
"SELECT hit_key, ts FROM rate_limit_hits").fetchall())
finally:
conn.close()
def _seed(self, rows):
conn = sqlite3.connect(self.db_path)
try:
conn.executemany(
"INSERT INTO rate_limit_hits(hit_key, ts) VALUES(?, ?)",
rows)
conn.commit()
finally:
conn.close()
def test_allow_does_not_delete_old_rows(self):
# 结构修复的核心断言:allow() 只 INSERT + 窗口内 COUNT,
# 不再 DELETE——过期行在 allow() 之后必须还在表里,只等
# 后台 sweep 来清。旧实现里这一步 DELETE 会把它们清掉。
lim = self._limiter(per_minute=10, window=60)
self.assertTrue(lim.allow("k"))
self.assertTrue(lim.allow("k"))
self._seed([("old", 500.0), ("k", 500.0)])
self.now[0] = 2000.0
self.assertTrue(lim.allow("k"))
rows = self._rows()
self.assertIn(("old", 500.0), rows)
self.assertIn(("k", 500.0), rows)
self.assertIn(("k", 2000.0), rows)
def test_windowed_count_ignores_expired_rows(self):
# COUNT 按窗口过滤:即使过期行还残留在表里,窗口滑过去
# 后计数也必须恢复,不依赖 sweep 先跑过。
lim = self._limiter(per_minute=3, window=60)
for _ in range(3):
self.assertTrue(lim.allow("k1"))
self.assertFalse(lim.allow("k1"))
self.now[0] += 61
self.assertTrue(lim.allow("k1"))
# 旧行仍在表里(allow 没删),但计数已不含它们。
self.assertEqual(len(self._rows()), 5)
def test_sweep_deletes_only_expired_rows(self):
lim = self._limiter(per_minute=10, window=60)
self.assertTrue(lim.allow("seed"))
self._seed([("old", 100.0), ("new", 1990.0)])
self.assertTrue(sweep_rate_limit_hits(self.db_path, 60, now=2000.0))
self.assertEqual(self._rows(), [("new", 1990.0)])
def test_sweep_on_fresh_db_is_noop_true(self):
# 表还不存在时 sweep 不应炸:建空表、返回 True。
self.assertTrue(sweep_rate_limit_hits(self.db_path, 60, now=2000.0))
self.assertEqual(self._rows(), [])