Skip to content

Commit f34b0b2

Browse files
Claudeclaude
andcommitted
tracker: make memory-store announce a uniform random peer sample
The previous ordered-scan-with-wraparound selection biased toward the low end of the peer-id key space: because ETS ordered_set keys are sorted (where Go maps iterate randomly) and real peer ids cluster, a random start key usually landed past every entry, so announces kept returning the same lowest-keyed peers instead of a varied subset. This starves high-keyed peers and diverges from chihaya's randomized map iteration. Replace it with an explicit uniform random sample over the swarm's keys of the requested kind. The Redis store already matches chihaya by taking the HKEYS-order prefix and is unchanged. Adds a regression test with clustered peer ids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DqKy7xpT2kp7ZATjLKsdhT
1 parent 85fd39f commit f34b0b2

2 files changed

Lines changed: 48 additions & 39 deletions

File tree

tracker/lib/bento/tracker/storage/memory.ex

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,15 @@ defmodule Bento.Tracker.Storage.Memory do
125125
peers =
126126
if seeder? do
127127
# Return as many leechers as possible.
128-
collect(shard, info_hash, @leecher, num_want, nil)
128+
sample(shard, info_hash, @leecher, num_want, nil)
129129
else
130130
# Return as many seeders as possible, then fill up with
131131
# leechers, excluding the announcing peer itself.
132-
seeders = collect(shard, info_hash, @seeder, num_want, nil)
132+
seeders = sample(shard, info_hash, @seeder, num_want, nil)
133133
remaining = num_want - length(seeders)
134134

135135
if remaining > 0 do
136-
seeders ++ collect(shard, info_hash, @leecher, remaining, peer_key(announcer))
136+
seeders ++ sample(shard, info_hash, @leecher, remaining, peer_key(announcer))
137137
else
138138
seeders
139139
end
@@ -365,44 +365,24 @@ defmodule Bento.Tracker.Storage.Memory do
365365
count_infohashes(shard, :ets.next(shard, {info_hash, 2, 0}), count + 1)
366366
end
367367

368-
# Collects up to num_want peers of the given kind, starting from a
369-
# random position in the swarm and wrapping around, so that repeated
370-
# announces return varying subsets - the equivalent of Go's randomized
371-
# map iteration order.
372-
defp collect(_shard, _info_hash, _kind, num_want, _exclude) when num_want <= 0, do: []
373-
374-
defp collect(shard, info_hash, kind, num_want, exclude) do
375-
start = :crypto.strong_rand_bytes(38)
376-
first = :ets.next(shard, {info_hash, kind, start})
377-
{peers, remaining} = walk(shard, first, info_hash, kind, nil, num_want, exclude, [])
378-
379-
if remaining > 0 do
380-
from_begin = :ets.next(shard, {info_hash, kind, 0})
381-
{peers, _} = walk(shard, from_begin, info_hash, kind, start, remaining, exclude, peers)
382-
peers
383-
else
384-
peers
385-
end
386-
end
387-
388-
# Walks keys of the form {info_hash, kind, pk} collecting decoded
389-
# peers. Stops at num_want, at the end of the kind's range, or - when
390-
# stop_pk is given - once pk passes it (the wrap-around boundary).
391-
defp walk(_shard, _key, _info_hash, _kind, _stop_pk, 0, _exclude, acc), do: {acc, 0}
368+
# Returns up to num_want peers of the given kind for the swarm, chosen
369+
# as a uniform random subset. chihaya achieves varied announce
370+
# responses by relying on Go's randomized map iteration order; an ETS
371+
# ordered_set is deterministic in key order, so we sample explicitly to
372+
# avoid biasing toward any region of the (clustered) peer-id key space.
373+
defp sample(_shard, _info_hash, _kind, num_want, _exclude) when num_want <= 0, do: []
392374

393-
defp walk(shard, {info_hash, kind, pk} = key, info_hash, kind, stop_pk, want, exclude, acc) do
394-
cond do
395-
stop_pk != nil and pk > stop_pk ->
396-
{acc, want}
375+
defp sample(shard, info_hash, kind, num_want, exclude) do
376+
keys = :ets.select(shard, [{{{info_hash, kind, :"$1"}, :_}, [], [:"$1"]}])
377+
keys = if exclude, do: List.delete(keys, exclude), else: keys
397378

398-
pk == exclude ->
399-
walk(shard, :ets.next(shard, key), info_hash, kind, stop_pk, want, exclude, acc)
400-
401-
true ->
402-
acc = [decode_peer_key(pk) | acc]
403-
walk(shard, :ets.next(shard, key), info_hash, kind, stop_pk, want - 1, exclude, acc)
404-
end
379+
keys
380+
|> take_random(num_want)
381+
|> Enum.map(&decode_peer_key/1)
405382
end
406383

407-
defp walk(_shard, _key, _info_hash, _kind, _stop_pk, want, _exclude, acc), do: {acc, want}
384+
# Enum.take_random with an explicit small-count fast path.
385+
defp take_random(list, n) do
386+
if length(list) <= n, do: list, else: Enum.take_random(list, n)
387+
end
408388
end

tracker/test/bento/tracker/storage/memory_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,33 @@ defmodule Bento.Tracker.Storage.MemoryTest do
8484

8585
assert Storage.stop(store) == :ok
8686
end
87+
88+
test "announce returns a varied random subset across repeated calls" do
89+
alias Bento.Tracker.Peer
90+
91+
{:ok, store} = Storage.new("memory", %{shard_count: 4})
92+
ih = String.duplicate("r", 20)
93+
94+
# Sequential low-valued peer IDs cluster at the low end of the key
95+
# space, the case that defeated the earlier ordered-scan selection.
96+
leechers = for i <- 1..40, do: %Peer{id: <<i::160>>, ip: {10, 0, 0, rem(i, 250)}, port: i}
97+
for peer <- leechers, do: :ok = Storage.put_leecher(store, ih, peer)
98+
99+
announcer = %Peer{id: <<999::160>>, ip: {10, 0, 1, 1}, port: 999}
100+
101+
subsets =
102+
for _ <- 1..20 do
103+
{:ok, peers} = Storage.announce_peers(store, ih, true, 5, announcer)
104+
assert length(peers) == 5
105+
# No duplicates within a single response.
106+
assert length(Enum.uniq_by(peers, &{&1.ip, &1.port})) == 5
107+
Enum.map(peers, & &1.port) |> Enum.sort()
108+
end
109+
110+
# The subset varies across calls rather than always returning the same
111+
# (lowest-keyed) peers.
112+
assert length(Enum.uniq(subsets)) > 1
113+
114+
assert Storage.stop(store) == :ok
115+
end
87116
end

0 commit comments

Comments
 (0)