Skip to content

Commit aa4382d

Browse files
committed
prio: order tied URLs per network and day, not per request
A tie-break that differs on every request spreads the probes on a network over different URLs, so fewer of them measure the same URL and can confirm each other's results. Last week, in most of the 30 most measured countries, 35-81% of the URLs a network measured on a day were measured by two or more runs that day. Seed the shuffle with the probe's country, network and UTC day instead: probes on the same network get tied URLs in the same order during a day, and in a new order the next day, so over several days the network still works through more of its list, and other networks in the country start elsewhere in it. TIE_BREAK_SLOT_SECONDS sets the slot; a shorter one spreads measurements over the list sooner, which matters for noticing new blocking quickly, with fewer probes per URL in each slot. The entries come from a query without ORDER BY, so they are sorted by URL before the seeded shuffle, or the same seed could give different orders. compute_priorities without a seed keeps the entries' order, for show_countries_prioritization.
1 parent baa0c7d commit aa4382d

2 files changed

Lines changed: 70 additions & 20 deletions

File tree

‎ooniapi/common/src/common/prio.py‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323

2424
from functools import lru_cache
2525
import random
26-
from typing import Annotated, Dict, List, Tuple
26+
import time
27+
from typing import Annotated, Dict, List, Optional, Tuple
2728
import logging
2829

2930
import sqlalchemy as sa
@@ -53,8 +54,24 @@ def match_prio_rule(cz, pr: dict) -> bool:
5354
return True
5455

5556

56-
def compute_priorities(entries: tuple, prio_rules: tuple) -> list:
57-
# Order based on (msmt_cnt / priority) to provide balancing
57+
# Probes on the same network get URLs that tie on weight in the same order
58+
# within a slot, so that they measure the same URLs and confirm each other's
59+
# results, and in a new order in the next slot, so that over several slots
60+
# they work through more of the list. A shorter slot spreads measurements
61+
# over the list sooner, with fewer probes per URL in each slot.
62+
TIE_BREAK_SLOT_SECONDS = 86400
63+
64+
65+
def tie_break_seed(probe_cc: str, probe_asn: int, now: Optional[float] = None) -> str:
66+
"""Seed for ordering a network's tied URLs during the current slot"""
67+
slot = int((time.time() if now is None else now) // TIE_BREAK_SLOT_SECONDS)
68+
return f"{probe_cc.upper()}:{probe_asn}:{slot}"
69+
70+
71+
def compute_priorities(entries: tuple, prio_rules: tuple, seed: Optional[str] = None) -> list:
72+
"""Order based on (msmt_cnt / priority) to provide balancing.
73+
With seed, URLs with the same weight come out in an order picked by the
74+
seed (see tie_break_seed), otherwise in the order of entries."""
5875
test_list = []
5976
for e in entries:
6077
# Calculate priority for an URL
@@ -68,11 +85,13 @@ def compute_priorities(entries: tuple, prio_rules: tuple) -> list:
6885
o["weight"] = priority / max(e["msmt_cnt"], 0.1)
6986
test_list.append(o)
7087

71-
# URLs with the same weight, common when measurement counts are low, come
72-
# out in a random order: probes that test only the top of the list then
73-
# don't all test the same URLs. sorted() is stable, so shuffling first
74-
# breaks the ties without changing the ranking.
75-
random.shuffle(test_list)
88+
if seed is not None:
89+
# entries come from a query without ORDER BY: sort them so that the
90+
# same seed gives the same order. sorted() is stable, so shuffling
91+
# first breaks ties between equal weights without changing the
92+
# ranking.
93+
test_list.sort(key=lambda k: (k["url"], k["cc"]))
94+
random.Random(seed).shuffle(test_list)
7695
return sorted(test_list, key=lambda k: k["weight"], reverse=True)
7796

7897

@@ -140,7 +159,7 @@ def generate_test_list(
140159
log.info("fetched %d url entries", len(entries))
141160
prio_rules = fetch_prioritization_rules(clickhouse, country_code)
142161
log.info("fetched %d priority rules", len(prio_rules))
143-
li = compute_priorities(entries, prio_rules)
162+
li = compute_priorities(entries, prio_rules, tie_break_seed(country_code, probe_asn))
144163
# Filter unwanted category codes, replace ZZ, trim priority <= 0
145164
out = []
146165
for entry in li:

‎ooniapi/services/ooniprobe/tests/test_prio.py‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import random
2+
13
import pytest
24

35
from ooniprobe.common import prio
@@ -187,7 +189,7 @@ async def test_debug_prioritization(client):
187189
assert resp.headers["content-type"] == "application/json"
188190

189191

190-
def test_compute_priorities_random_tie_break():
192+
def tied_entries():
191193
# 20 URLs with the same weight and one with a higher weight
192194
entries = [
193195
{"category_code": "NEWS", "domain": f"d{i}.org", "url": f"https://d{i}.org/", "cc": "ZZ", "msmt_cnt": 0}
@@ -198,13 +200,42 @@ def test_compute_priorities_random_tie_break():
198200
{"category_code": "NEWS", "cc": "*", "domain": "*", "priority": 100, "url": "*"},
199201
{"category_code": "*", "cc": "*", "domain": "top.org", "priority": 100, "url": "*"},
200202
]
201-
orders = set()
202-
for _ in range(20):
203-
out = prio.compute_priorities(entries, prio_rules)
204-
# the ranking holds: highest weight first, weights never increase
205-
assert out[0]["url"] == "https://top.org/"
206-
assert [o["weight"] for o in out] == sorted((o["weight"] for o in out), reverse=True)
207-
orders.add(tuple(o["url"] for o in out[1:]))
208-
# the 20 tied URLs come out in different orders; all 20 runs giving the
209-
# same order has probability 20!^-19
210-
assert len(orders) > 1
203+
return entries, prio_rules
204+
205+
206+
def urls(out):
207+
return [o["url"] for o in out]
208+
209+
210+
def test_compute_priorities_seeded_tie_break():
211+
entries, prio_rules = tied_entries()
212+
out = prio.compute_priorities(entries, prio_rules, seed="IT:30722:20368")
213+
# the ranking holds: highest weight first, weights never increase
214+
assert out[0]["url"] == "https://top.org/"
215+
assert [o["weight"] for o in out] == sorted((o["weight"] for o in out), reverse=True)
216+
217+
# the same seed gives the same order, whatever order the entries come in
218+
# (the query that fetches them has no ORDER BY)
219+
shuffled = list(entries)
220+
random.Random(1).shuffle(shuffled)
221+
assert urls(prio.compute_priorities(shuffled, prio_rules, seed="IT:30722:20368")) == urls(out)
222+
223+
# other networks and other slots get the tied URLs in other orders; any
224+
# two of these being equal has probability 1/20!
225+
seeds = ["IT:30722:20368", "IT:30722:20369", "IT:3269:20368", "DE:30722:20368"]
226+
assert len({tuple(urls(prio.compute_priorities(entries, prio_rules, seed=s))) for s in seeds}) == len(seeds)
227+
228+
229+
def test_compute_priorities_without_seed_keeps_entry_order():
230+
entries, prio_rules = tied_entries()
231+
out = prio.compute_priorities(entries, prio_rules)
232+
assert urls(out) == ["https://top.org/"] + [f"https://d{i}.org/" for i in range(20)]
233+
234+
235+
def test_tie_break_seed_changes_per_network_and_day():
236+
day = 86400
237+
t = 20368 * day + 3600
238+
assert prio.tie_break_seed("it", 30722, t) == prio.tie_break_seed("IT", 30722, t + 3600)
239+
assert prio.tie_break_seed("IT", 30722, t) != prio.tie_break_seed("IT", 30722, t + day)
240+
assert prio.tie_break_seed("IT", 30722, t) != prio.tie_break_seed("IT", 3269, t)
241+
assert prio.tie_break_seed("IT", 30722, t) != prio.tie_break_seed("DE", 30722, t)

0 commit comments

Comments
 (0)