|
1 | | -from collections.abc import Mapping, Sequence |
| 1 | +from collections.abc import Mapping |
2 | 2 |
|
3 | 3 | import pytest |
4 | 4 |
|
5 | | -from sentry.dynamic_sampling.models.base import InvalidModelInputError |
6 | 5 | from sentry.dynamic_sampling.models.common import RebalancedItem, sum_classes_counts |
7 | 6 | from sentry.dynamic_sampling.models.transactions_rebalancing import ( |
8 | 7 | TransactionsRebalancingInput, |
@@ -85,274 +84,3 @@ def test_maintains_overall_sample_rate( |
85 | 84 | new_sampled_transactions = get_num_sampled_elements(transactions, trans_dict, global_rate) |
86 | 85 |
|
87 | 86 | assert new_sampled_transactions == pytest.approx(old_sampled_transactions) |
88 | | - |
89 | | - |
90 | | -@pytest.mark.parametrize("sample_rate", sample_rates) |
91 | | -@pytest.mark.parametrize("transactions", test_resample_cases) |
92 | | -@pytest.mark.parametrize("idx_low,idx_high", excluded_transactions) |
93 | | -def test_explicit_elements_ideal_rate( |
94 | | - transactions_rebalancing_model, sample_rate, transactions, idx_low, idx_high |
95 | | -): |
96 | | - """ |
97 | | - Tests that the explicitly specified elements are sampled at their ideal rate. |
98 | | -
|
99 | | - This test is performed at intensity=1.0 |
100 | | - Ideal sample rate means that the resulting number of sampled elements is the minimum between: |
101 | | - * all transactions in the class (sampled at rate 1.0) |
102 | | - * the budget per transaction |
103 | | - """ |
104 | | - explict_transactions = transactions[idx_low:idx_high] |
105 | | - total = sum_classes_counts(transactions) |
106 | | - total_classes = len(transactions) |
107 | | - |
108 | | - trans, global_rate = transactions_rebalancing_model.run( |
109 | | - TransactionsRebalancingInput( |
110 | | - classes=explict_transactions, |
111 | | - sample_rate=sample_rate, |
112 | | - total_num_classes=total_classes, |
113 | | - total=total, |
114 | | - intensity=1, |
115 | | - ), |
116 | | - ) |
117 | | - |
118 | | - ideal_number_of_elements_per_class = total * sample_rate / total_classes |
119 | | - |
120 | | - trans_dict = {t.id: t.new_sample_rate for t in trans} |
121 | | - |
122 | | - for transaction in explict_transactions: |
123 | | - count = transaction.count |
124 | | - actual_rate = trans_dict[transaction.id] |
125 | | - |
126 | | - if ideal_number_of_elements_per_class > count: |
127 | | - assert actual_rate == 1.0 # tiny transactions not sampled |
128 | | - else: |
129 | | - assert ( |
130 | | - actual_rate * count == pytest.approx(ideal_number_of_elements_per_class) |
131 | | - or actual_rate * count >= ideal_number_of_elements_per_class |
132 | | - ) |
133 | | - |
134 | | - |
135 | | -def _run( |
136 | | - model: TransactionsRebalancingModel, |
137 | | - *, |
138 | | - classes: list[RebalancedItem], |
139 | | - sample_rate: float, |
140 | | - total_num_classes: int, |
141 | | - total: float, |
142 | | - intensity: float = 1.0, |
143 | | - min_sample_rate: float = 0.0, |
144 | | -) -> tuple[list[RebalancedItem], float]: |
145 | | - return model.run( |
146 | | - TransactionsRebalancingInput( |
147 | | - classes=classes, |
148 | | - sample_rate=sample_rate, |
149 | | - total_num_classes=total_num_classes, |
150 | | - total=total, |
151 | | - intensity=intensity, |
152 | | - min_sample_rate=min_sample_rate, |
153 | | - ) |
154 | | - ) |
155 | | - |
156 | | - |
157 | | -def _overall_kept( |
158 | | - explicit_rates: Sequence[RebalancedItem], implicit_rate: float, total: float |
159 | | -) -> float: |
160 | | - """Expected number of kept samples across explicit classes and the implicit (tail) classes.""" |
161 | | - explicit_kept = sum(item.new_sample_rate * item.count for item in explicit_rates) |
162 | | - total_implicit = total - sum_classes_counts(list(explicit_rates)) |
163 | | - return explicit_kept + implicit_rate * total_implicit |
164 | | - |
165 | | - |
166 | | -def test_high_class_count_crushes_dominant_transaction_without_floor( |
167 | | - transactions_rebalancing_model, |
168 | | -) -> None: |
169 | | - """ |
170 | | - Reproduces TET-2535: one transaction carrying half the project volume while competing against a |
171 | | - very large number of low-volume classes. With no floor the per-class ideal collapses, the |
172 | | - dominant transaction is driven to a ~1e-6 rate (a ~1,000,000x extrapolation factor) and is |
173 | | - expected to keep only ~1 raw sample - the spiky "lottery" behaviour we want to remove. |
174 | | - """ |
175 | | - big = RebalancedItem(id="big", count=1_000_000) |
176 | | - |
177 | | - explicit_rates, implicit_rate = _run( |
178 | | - transactions_rebalancing_model, |
179 | | - classes=[big], |
180 | | - sample_rate=0.05, |
181 | | - total_num_classes=100_000, |
182 | | - total=2_000_000, |
183 | | - min_sample_rate=0.0, |
184 | | - ) |
185 | | - |
186 | | - (big_rate,) = explicit_rates |
187 | | - assert big_rate.new_sample_rate == pytest.approx(1e-6) |
188 | | - # the dominant transaction expects to keep ~1 raw sample, yet the tail is sampled ~100,000x denser |
189 | | - assert big_rate.new_sample_rate * big_rate.count == pytest.approx(1.0) |
190 | | - assert implicit_rate == pytest.approx(0.099999) |
191 | | - assert implicit_rate / big_rate.new_sample_rate == pytest.approx(99999.0) |
192 | | - |
193 | | - |
194 | | -def test_min_sample_rate_floors_dominant_transaction(transactions_rebalancing_model) -> None: |
195 | | - """ |
196 | | - Same scenario as above, but with the floor enabled: the dominant transaction's rate is floored |
197 | | - so its extrapolation factor is capped at 1 / min_sample_rate, the cost is reclaimed from the |
198 | | - implicit tail, and the overall sampling rate is still maintained exactly. |
199 | | - """ |
200 | | - big = RebalancedItem(id="big", count=1_000_000) |
201 | | - |
202 | | - explicit_rates, implicit_rate = _run( |
203 | | - transactions_rebalancing_model, |
204 | | - classes=[big], |
205 | | - sample_rate=0.05, |
206 | | - total_num_classes=100_000, |
207 | | - total=2_000_000, |
208 | | - min_sample_rate=0.001, |
209 | | - ) |
210 | | - |
211 | | - (big_rate,) = explicit_rates |
212 | | - # rate floored to 0.001 => extrapolation factor capped at 1000 (was 1,000,000) |
213 | | - assert big_rate.new_sample_rate == pytest.approx(0.001) |
214 | | - # expected kept samples jump from ~1 to ~1000, killing the shot noise |
215 | | - assert big_rate.new_sample_rate * big_rate.count == pytest.approx(1000.0) |
216 | | - # the budget is reclaimed by lowering the tail rate, not by exceeding the overall budget |
217 | | - assert implicit_rate == pytest.approx(0.099) |
218 | | - assert _overall_kept(explicit_rates, implicit_rate, total=2_000_000) == pytest.approx( |
219 | | - 0.05 * 2_000_000 |
220 | | - ) |
221 | | - |
222 | | - |
223 | | -def test_min_sample_rate_with_many_explicit_transactions(transactions_rebalancing_model) -> None: |
224 | | - """ |
225 | | - Production-shaped case: 30 high-volume explicit transactions (60% of volume) competing with a |
226 | | - ~50k-class tail. Without the floor every head transaction collapses to a 5e-5 rate (20,000x |
227 | | - factor); with the floor they are lifted to the floor and the tail rate barely moves. |
228 | | - """ |
229 | | - classes = [RebalancedItem(id=f"big{i}", count=20_000) for i in range(30)] |
230 | | - |
231 | | - no_floor_rates, no_floor_implicit = _run( |
232 | | - transactions_rebalancing_model, |
233 | | - classes=classes, |
234 | | - sample_rate=0.05, |
235 | | - total_num_classes=50_000, |
236 | | - total=1_000_000, |
237 | | - min_sample_rate=0.0, |
238 | | - ) |
239 | | - assert all(item.new_sample_rate == pytest.approx(5e-5) for item in no_floor_rates) |
240 | | - |
241 | | - floored_rates, floored_implicit = _run( |
242 | | - transactions_rebalancing_model, |
243 | | - classes=classes, |
244 | | - sample_rate=0.05, |
245 | | - total_num_classes=50_000, |
246 | | - total=1_000_000, |
247 | | - min_sample_rate=0.001, |
248 | | - ) |
249 | | - |
250 | | - assert all(item.new_sample_rate == pytest.approx(0.001) for item in floored_rates) |
251 | | - # reclaimed from the tail; the implicit rate drops but stays well above zero |
252 | | - assert floored_implicit < no_floor_implicit |
253 | | - assert floored_implicit == pytest.approx(0.1235) |
254 | | - assert _overall_kept(floored_rates, floored_implicit, total=1_000_000) == pytest.approx( |
255 | | - 0.05 * 1_000_000 |
256 | | - ) |
257 | | - |
258 | | - |
259 | | -def test_min_sample_rate_clamped_to_overall_rate(transactions_rebalancing_model) -> None: |
260 | | - """ |
261 | | - For a project whose overall rate is already below the configured floor, the floor is clamped to |
262 | | - the overall rate, so a class is never sampled above the project rate and the budget cannot be |
263 | | - overshot. |
264 | | - """ |
265 | | - big = RebalancedItem(id="big", count=1_000_000) |
266 | | - |
267 | | - explicit_rates, implicit_rate = _run( |
268 | | - transactions_rebalancing_model, |
269 | | - classes=[big], |
270 | | - sample_rate=0.0005, |
271 | | - total_num_classes=100_000, |
272 | | - total=2_000_000, |
273 | | - min_sample_rate=0.001, |
274 | | - ) |
275 | | - |
276 | | - (big_rate,) = explicit_rates |
277 | | - assert big_rate.new_sample_rate == pytest.approx(0.0005) |
278 | | - assert big_rate.new_sample_rate <= 0.0005 |
279 | | - assert _overall_kept(explicit_rates, implicit_rate, total=2_000_000) == pytest.approx( |
280 | | - 0.0005 * 2_000_000 |
281 | | - ) |
282 | | - |
283 | | - |
284 | | -def test_min_sample_rate_inert_when_rates_healthy(transactions_rebalancing_model) -> None: |
285 | | - """When the natural rates already sit above the floor, enabling it changes nothing.""" |
286 | | - classes = [RebalancedItem(id=f"big{i}", count=1000) for i in range(3)] |
287 | | - |
288 | | - base_rates, base_implicit = _run( |
289 | | - transactions_rebalancing_model, |
290 | | - classes=classes, |
291 | | - sample_rate=0.5, |
292 | | - total_num_classes=20, |
293 | | - total=10_000, |
294 | | - min_sample_rate=0.0, |
295 | | - ) |
296 | | - floored_rates, floored_implicit = _run( |
297 | | - transactions_rebalancing_model, |
298 | | - classes=classes, |
299 | | - sample_rate=0.5, |
300 | | - total_num_classes=20, |
301 | | - total=10_000, |
302 | | - min_sample_rate=1e-6, |
303 | | - ) |
304 | | - |
305 | | - assert {item.id: item.new_sample_rate for item in floored_rates} == { |
306 | | - item.id: item.new_sample_rate for item in base_rates |
307 | | - } |
308 | | - assert floored_implicit == pytest.approx(base_implicit) |
309 | | - |
310 | | - |
311 | | -def test_min_sample_rate_out_of_range_is_invalid(transactions_rebalancing_model) -> None: |
312 | | - with pytest.raises(InvalidModelInputError): |
313 | | - _run( |
314 | | - transactions_rebalancing_model, |
315 | | - classes=[RebalancedItem(id="big", count=1000)], |
316 | | - sample_rate=0.5, |
317 | | - total_num_classes=10, |
318 | | - total=10_000, |
319 | | - min_sample_rate=1.5, |
320 | | - ) |
321 | | - |
322 | | - |
323 | | -def test_total_num_classes_mismatch(transactions_rebalancing_model) -> None: |
324 | | - """ |
325 | | - Simple test case that checks that the model is resilient to cases where the |
326 | | - reported total number of classes is less than the number of passed classes |
327 | | - """ |
328 | | - sample_rate = 0.9 |
329 | | - transactions = create_transaction_counts(big=3, med=4, small=2) |
330 | | - explict_transactions = transactions[0:None] |
331 | | - total = sum_classes_counts(transactions) |
332 | | - total_classes = len(transactions) |
333 | | - |
334 | | - trans, global_rate = transactions_rebalancing_model.run( |
335 | | - TransactionsRebalancingInput( |
336 | | - classes=explict_transactions, |
337 | | - sample_rate=sample_rate, |
338 | | - total_num_classes=total_classes - 1, |
339 | | - total=total, |
340 | | - intensity=1, |
341 | | - ), |
342 | | - ) |
343 | | - |
344 | | - ideal_number_of_elements_per_class = total * sample_rate / total_classes |
345 | | - |
346 | | - trans_dict = {t.id: t.new_sample_rate for t in trans} |
347 | | - |
348 | | - for transaction in explict_transactions: |
349 | | - count = transaction.count |
350 | | - actual_rate = trans_dict[transaction.id] |
351 | | - |
352 | | - if ideal_number_of_elements_per_class > count: |
353 | | - assert actual_rate == 1.0 # tiny transactions not sampled |
354 | | - else: |
355 | | - assert ( |
356 | | - actual_rate * count == pytest.approx(ideal_number_of_elements_per_class) |
357 | | - or actual_rate * count >= ideal_number_of_elements_per_class |
358 | | - ) |
0 commit comments