返回 last30days-skill
test_reddit_keyless_memo.py
根目录 / tests / test_reddit_keyless_memo.py
1 """Run-scoped memo for keyless Reddit GETs.
2
3 Subreddit listings, listing RSS feeds, arctic supplements, and shreddit comment
4 pages are byte-identical across subqueries (the lane is dispatched with the raw
5 topic every time), so a four-subquery run fetched each of them four times.
6 """
7
8 import threading
9 from unittest import mock
10
11 import pytest
12
13 from lib import http
14
15
16 @pytest.fixture(autouse=True)
17 def _fresh_memo():
18 http.reset_reddit_keyless_memo()
19 yield
20 http.reset_reddit_keyless_memo()
21
22
23 def test_second_call_for_same_url_is_served_from_memo():
24 with mock.patch.object(http, "get_text", return_value="<feed/>") as get_text, \
25 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire") as acquire:
26 first = http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/top.rss?t=month")
27 second = http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/top.rss?t=month")
28 assert first == second == "<feed/>"
29 assert get_text.call_count == 1
30 assert acquire.call_count == 1, "a memo hit must not spend a limiter token"
31
32
33 def test_failed_fetch_is_not_memoized():
34 with mock.patch.object(http, "get_text", side_effect=[None, "<feed/>"]) as get_text, \
35 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
36 assert http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/hot.rss") is None
37 assert http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/hot.rss") == "<feed/>"
38 assert get_text.call_count == 2
39
40
41 def test_reset_clears_the_memo():
42 with mock.patch.object(http, "get_text", return_value="<feed/>") as get_text, \
43 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
44 http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/new.rss")
45 http.reset_reddit_keyless_memo()
46 http.reddit_keyless_get_text("https://www.reddit.com/r/Kanye/new.rss")
47 assert get_text.call_count == 2
48
49
50 def test_memo_is_bounded():
51 with mock.patch.object(http, "get_text", return_value="<feed/>") as get_text, \
52 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
53 for i in range(http.REDDIT_KEYLESS_MEMO_MAX + 1):
54 http.reddit_keyless_get_text(f"https://www.reddit.com/r/s{i}/top.rss")
55 # The very first URL was evicted; the most recent one is still cached.
56 http.reddit_keyless_get_text("https://www.reddit.com/r/s0/top.rss")
57 http.reddit_keyless_get_text(f"https://www.reddit.com/r/s{http.REDDIT_KEYLESS_MEMO_MAX}/top.rss")
58 assert get_text.call_count == http.REDDIT_KEYLESS_MEMO_MAX + 2
59
60
61 def test_concurrent_requesters_share_one_in_flight_fetch():
62 release = threading.Event()
63 calls = []
64
65 def slow_get_text(url, **kwargs):
66 calls.append(url)
67 release.wait(timeout=5)
68 return "<feed/>"
69
70 results = []
71 with mock.patch.object(http, "get_text", side_effect=slow_get_text), \
72 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
73 url = "https://www.reddit.com/svc/shreddit/community-more-posts/top/?name=Kanye&t=month"
74 threads = [threading.Thread(target=lambda: results.append(http.reddit_keyless_get_text(url))) for _ in range(4)]
75 for t in threads:
76 t.start()
77 # Give every thread a chance to reach the memo before the owner finishes.
78 deadline = threading.Event()
79 deadline.wait(timeout=0.2)
80 release.set()
81 for t in threads:
82 t.join(timeout=5)
83 assert results == ["<feed/>"] * 4
84 assert calls == [url], "four concurrent requesters must share one fetch"
85
86
87 def test_retry_helper_goes_through_the_memo():
88 with mock.patch.object(http, "get_text", return_value="<feed/>") as get_text, \
89 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
90 body, err = http.reddit_keyless_get_text_retry_429("https://www.reddit.com/r/Kanye/top.rss")
91 body2, err2 = http.reddit_keyless_get_text_retry_429("https://www.reddit.com/r/Kanye/top.rss")
92 assert (body, err) == ("<feed/>", None) == (body2, err2)
93 assert get_text.call_count == 1
94
95
96 def test_waiters_do_not_stampede_when_the_owner_fails():
97 """If the in-flight owner's fetch fails, the waiters elect one new owner
98 and share its fetch instead of each issuing their own."""
99 release = threading.Event()
100 calls = []
101 lock = threading.Lock()
102
103 def get_text(url, **kwargs):
104 with lock:
105 calls.append(url)
106 n = len(calls)
107 release.wait(timeout=5)
108 return None if n == 1 else "<feed/>"
109
110 results = []
111 with mock.patch.object(http, "get_text", side_effect=get_text), \
112 mock.patch.object(http.REDDIT_KEYLESS_LIMITER, "acquire"):
113 url = "https://www.reddit.com/r/Kanye/top.rss?t=month"
114 threads = [threading.Thread(target=lambda: results.append(http.reddit_keyless_get_text(url))) for _ in range(4)]
115 for t in threads:
116 t.start()
117 threading.Event().wait(timeout=0.2)
118 release.set()
119 for t in threads:
120 t.join(timeout=10)
121 assert sorted(results, key=str) == [None, "<feed/>", "<feed/>", "<feed/>"] or results.count("<feed/>") >= 3
122 assert len(calls) <= 2, f"expected the owner's fetch plus one retry, got {len(calls)}"
123
123 lines PYTHON