Author SHA1 Message Date
clawbot 946c8a4b41 Make the routewatch tests pass on a loaded host (closes #213)
check / check (push) Canceled after 0s
Five prefix handler tests gave HandleMessage two seconds to apply a full
batch of 25,000 prefixes, which under the race detector on a loaded host
takes longer. A test now waits up to stepTimeout, 15 seconds, for each step
it expects.

The slowest tests filled production limits, such as the peering handler's
500,000 paths. The peering handler now takes its path limit, and the prefix
handler its limit on one write of the table's changes, as they take their
timer interval. Those tests give small limits, and the tests of the maximum
batch size lower it to one batch. This also fixes
#215.

The test helpers bound every wait, Stop included, so a test that hangs in
one fails with its own message.

Model: opus-5-5
2026-10-28 00:06:53 +00:00
16 changed files with 508 additions and 1472 deletions
+16 -29
View File
@@ -429,12 +429,10 @@ the routing table, and everything else from the database.
- Parses BGP UPDATE messages from JSON stream
- Dispatches to registered handlers based on message type
- Each handler has its own queue with backpressure handling
- Handlers batch writes for efficiency. The AS, peer and prefix handlers
write a batch when it reaches a size, or after a time:
- the AS handler at 30,000 ASes, or after 2 seconds;
- the peer handler at 10,000 updates, or after 2 seconds;
- the prefix handler at 25,000 updates, or after 1 second.
- The peering handler writes the peerings it has collected every 30 seconds
- Handlers batch writes for efficiency: the AS, peer and prefix handlers
write 30,000, 10,000 and 25,000 updates at a time, or after 2, 2 and 1
seconds; the peering handler writes the peerings it has collected every 30
seconds
4. **Handler Details**
- **ASHandler**: Tracks all ASNs seen in AS paths
@@ -444,9 +442,7 @@ the routing table, and everything else from the database.
- An announcement gives the peer a route to the prefix.
- A withdrawal removes the peer's route. So does an announcement whose
AS path is empty or holds AS 0 anywhere.
- A peer whose BGP session goes down leaves every prefix. The handler
holds back the peer's later updates until the peer has left every
prefix, then applies them in order.
- A peer whose BGP session goes down leaves every prefix.
Every second it writes the changed prefix and origin AS pairs to the
`routes` table. A write takes at most 20,000 pairs, and the rest follow
@@ -582,8 +578,8 @@ Budget:
- Go heap: a 2.5 GiB soft limit (`GOMEMLIMIT=2560MiB`, set in the image).
- The routing table takes about 700 bytes per prefix and origin AS. A full
table has about 1.4 million of these pairs, so it takes about 1 GB.
- The rest of the heap takes up to about 0.5 GiB. Most of it is what the
handlers hold, described below.
- The rest of the heap takes up to about 0.5 GiB. Most of it is the
handlers' batches and queues, described below.
- The remaining 1 GiB is room for the garbage collector. With much less, it
would run almost all the time.
- SQLite: 656 MiB of page cache.
@@ -636,25 +632,16 @@ What happens at each limit:
`STATE` message, which it holds, in feed order, until there is room: a few
hundred bytes each, about ten a second.
- Handler batches: while the AS, peer or prefix handler writes a batch to the
database, the updates that arrive collect in its next batch.
- The AS handler's batch holds one entry per AS, with the earliest and the
latest time of its updates. About 80,000 ASes appear in the feed, so the
batch takes at most about 10 MiB, however fast the feed runs.
- The peer handler's batch stops at 100,000 updates (`peerBatchMaxSize`),
about 10 MiB.
- The prefix handler's batch stops at 250,000 updates
(`prefixBatchMaxSize`), about 15 MiB.
database, the updates that arrive collect in its next batch. That batch stops
at ten times the batch size:
- 300,000 updates for the AS handler (`asnBatchMaxSize`, about 10 MiB);
- 100,000 for the peer handler (`peerBatchMaxSize`, about 10 MiB);
- 250,000 for the prefix handler (`prefixBatchMaxSize`, about 15 MiB).
At that maximum the peer or prefix handler takes nothing more off its queue
until the write ends. The queue then fills, and the streamer drops messages
as above. With the batches being written, the three handlers' batches take
at most about 0.1 GiB of the Go heap.
- Held-back messages: the prefix handler holds back a peer's messages after the
peer's session goes down. It applies them once the peer has left every prefix.
It holds at most 20,000 such messages (`maxHeldBackMessages`), about 20 MiB.
At that number it takes nothing more off its queue until it has applied one of
them.
At that size the handler takes nothing more off its queue until the write
ends. The queue then fills, and the streamer drops messages as above. With
the batches being written, the three handlers' batches take at most about
0.1 GiB of the Go heap.
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
with the goroutine count and Go memory figures.
+254 -332
View File
@@ -10,206 +10,143 @@
# Status
RouteWatch is pre-1.0, with no git tags. `main` last took `next` on 2026-10-27,
through https://git.eeqj.de/sneak/routewatch/pulls/166.
https://git.eeqj.de/sneak/routewatch/pulls/202 carries the commits since then
into `main`. It is labelled `merge-ready` and waits for sneak.
The linter and the tests are phases of the Docker build. The Gitea workflow runs
`script/cibuild` on every push. That script runs the two phases and checks the
formatting of the Go code and the markdown. Then it builds the image.
The image sets memory ceilings for a 5 GiB container (README "Memory"). README
"Running under upaas" says how to run it.
A five-day run of `1c65656` on the live feed, from 27 October to 1 November,
found three problems:
- The prefix handler dropped updates while a peer's session went down
(https://git.eeqj.de/sneak/routewatch/issues/216).
- The AS handler dropped updates when the feed burst
(https://git.eeqj.de/sneak/routewatch/issues/217).
- The WHOIS fetcher kept asking a registry that had refused it
(https://git.eeqj.de/sneak/routewatch/issues/218).
All three are fixed on `next`. So is
https://git.eeqj.de/sneak/routewatch/issues/222, which the third fix brought up.
A 24-hour run of `26d939a` on the live feed is checking whether any handler
still drops updates.
The README rewrite, https://git.eeqj.de/sneak/routewatch/pulls/210, has failed
three reviews. It is paused until sneak has reviewed it. It is part of
https://git.eeqj.de/sneak/routewatch/issues/157.
Two questions wait for sneak: which history the prefix page keeps
(https://git.eeqj.de/sneak/routewatch/issues/161), and which fields the IP
lookup answer gains (https://git.eeqj.de/sneak/routewatch/issues/203).
The order of the project's remaining work is kept on
https://git.eeqj.de/sneak/routewatch/issues/86.
pre-1.0. No git tags. `main` last took `next` on 2026-10-06, through
https://git.eeqj.de/sneak/routewatch/pulls/6. The linter and the tests are
phases of the Docker build, and the Gitea workflow runs `script/cibuild` (the
format check of the Go code and the markdown, both phases, then the image) on
every push. The image sets memory ceilings for a 5 GiB container (README
"Memory") and the README says how to run it under upaas (README "Running under
upaas"). A 25-hour run of `a9b6860` on the live feed peaked at about 0.6 GiB
(https://git.eeqj.de/sneak/routewatch/issues/3). A run of `825afa2` on the live
feed found the prefix handler dropping about a quarter of its updates because
its route writes were slower than the feed
(https://git.eeqj.de/sneak/routewatch/issues/96); the cheaper writes since then
are measured by a benchmark, not yet on the live feed.
# Next Step
Post the outcome of the 24-hour run of `26d939a` on
https://git.eeqj.de/sneak/routewatch/issues/216: whether any handler still drops
updates, and the process's peak memory against 5 GiB. File an issue for each
problem the run finds.
Run `next` on the live feed, as README "Running under upaas" describes, until
the routing table stops growing. Post its size and memory figures on
https://git.eeqj.de/sneak/routewatch/issues/122. The same run makes the final
check https://git.eeqj.de/sneak/routewatch/issues/96 names: whether the prefix
handler still drops updates after the first hour. If it does, a new issue
follows with the numbers. Then an audit of the code at the commit that run
tested.
# Completed Steps
- 2026-11-02: `TODO.md` is rewritten from scratch, sentence by sentence, against
the rules of https://git.eeqj.de/sneak/routewatch/issues/157. Every Completed
Steps entry keeps its date, its facts and its issue. Status and Next Step
describe the repo as the tracker shows it (part of #157)
- 2026-11-02: A skipped WHOIS lookup now makes the fetcher wait longer before
the next one, as a failed lookup does. A lookup is skipped when its RIR is
paused. An RIR is one of the five regional Internet registries. The longest
wait is one minute. The status page does not count a skip as a failure. A skip
used to leave the wait as it was. Each lookup asks IANA which RIR holds the AS
before it finds that RIR paused. So while most stale ASes belonged to a paused
RIR, the fetcher could ask IANA every second (closes #222)
- 2026-11-02: A peer's session going down no longer stops the prefix handler.
The handler runs the routing table's `PeerDown` on a goroutine of its own, one
peer at a time. Meanwhile it goes on taking messages. `PeerDown` holds the
table's lock for 10,000 prefixes at a time, then lets the lock go for as long
again. The handler holds back the down peer's later messages until that peer's
`PeerDown` returns. Then the handler applies them in order, one at a time, and
takes new messages in between. It holds back at most 20,000 messages. At that
number, `HandleMessage` waits until one of them is applied. When `Stop` runs
past its deadline, it logs how many held-back messages were not applied
(closes #216)
- 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour
after the server refuses a query. NIC.br is Brazil's registry. A refused or
reset connection is a refusal. So is an answer with an `%ERROR:201:` or
`%ERROR:202:` line. Lookups at the other servers go on. An AS whose RIR is
paused is looked up after the pause. While NIC.br is paused, a Brazilian AS
gets the details from LACNIC's answer. IANA is never paused, because a pause
of IANA would stop every lookup. A refusal by IANA is an ordinary failure.
NIC.br is also paused for an hour after it says RouteWatch has passed its
query limit. Before, the fetcher asked a server again within seconds of a
refusal (closes #218)
- 2026-11-02: The AS handler never waits for a database write. Its batch holds
one entry per AS, with the earliest and the latest time of its updates. So the
batch no longer grows with the feed's rate. It used to hold one entry for each
AS of each path. At 300,000 entries it waited for the write in progress. In a
burst of the feed its queue then filled, and the streamer dropped messages for
it (closes #217)
- 2026-10-27: The tests of `internal/routewatch` no longer fail on a loaded
- 2026-10-27: the tests of `internal/routewatch` no longer fail on a loaded
host. Five of them gave `HandleMessage` two seconds to apply a full prefix
batch. Under the race detector that can take longer. A test now waits up to 15
seconds for each step it expects. The tests of the handlers' limits now give
the handlers small limits. So the package no longer runs past its 90-second
batch, which under the race detector can take longer. A test now waits up to
15 seconds for each step it expects. The tests of the handlers' limits give
the handlers small ones, so the package no longer runs past its 90-second
limit (closes #213, #215)
- 2026-10-27: The server works out the home page's cards every minute until it
stops. It used to work them out only once, at startup. The end of the context
given to `Start` stopped the refreshes (closes #211)
- 2026-10-27: README "Rationale" says why RouteWatch exists. RouteWatch answers
our other services' questions about IP addresses, from its own routing table
and the WHOIS details it keeps. The section still says what RIS Live does not
give (closes #204)
- 2026-10-27: Every JSON answer carries `@meta`. A panic on a JSON route answers
- 2026-10-27: the server works out the home page's cards every minute until it
stops. It used to work them out only once, at startup: the end of the context
that `Start` is given stopped the refreshes (closes #211)
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
services' questions about IP addresses, from its own routing table and the
WHOIS details it keeps. It still says what RIS Live does not give (closes
#204)
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
500 with the JSON error body. `/status.json` and the health check answer 405
with the JSON error body to any method they do not take. The 500 and the 405
used to have an empty body. The home page's IP lookup shows the whole JSON
answer, `@meta` included. It used to show only the answer's `data`, or only
the message of an error answer. A test checks `@meta` on every JSON route, in
its successes and its errors (closes #201)
- 2026-10-27: Every value with a unit in a JSON answer is a number. The unit is
at the end of the field name. `@meta.execution_time` is now
`execution_time_ms`. In the stats, `uptime` is now `uptime_seconds`,
`connection_duration` is now `connection_duration_seconds`, and `memory_usage`
is now `memory_usage_bytes`. `whois_stats.current_interval` is now
`whois_stats.current_interval_seconds`. They used to be strings such as "12
ms", "1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the
feed is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
`REPO_POLICIES.md` is re-vendored (closes #199)
- 2026-10-27: Every result of the IP lookup has the same fields. A field without
a value is null. It used to be left out. An address with no route in the
routing table has every route and AS field set to null. `ptr` is an empty list
when there are no PTR names. The new field `num_origins` gives the number of
origin ASes of the netblock, and `origin_asns` gives which they are (closes
#200)
- 2026-10-27: With `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
unit at the end of its field name. `@meta.execution_time` is now
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
`memory_usage` are now `uptime_seconds`, `connection_duration_seconds` and
`memory_usage_bytes`. `whois_stats.current_interval` is now
`current_interval_seconds`. They used to be strings such as "12 ms",
"1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the feed
is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
`REPO_POLICIES.md` re-vendored (closes #199)
- 2026-10-27: every result of the IP lookup has the same fields. A field without
a value is null. It used to be left out. An address with no live route has
every route and AS field set to null, and `ptr` is an empty list when there
are no PTR names. New fields give the number of origin ASes of the netblock,
in `num_origins`, and which they are, in `origin_asns` (closes #200)
- 2026-10-27: with `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
the private addresses of RFC 1918. `/ip/`, the home page's `curl` card and the
request log then give the client's address from `X-Forwarded-For`. They used
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default.
`TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: The home page has cards with figures of the routing table. One
card shows the newest and the oldest kept route. One shows the newest and the
oldest updated AS. One shows the AS with the most prefixes. One shows the ASes
with a single prefix. One shows fifteen well-known networks. Two cards count
the bogon announcements and the prefixes with more than one origin AS. They
link to the new pages `/bogons` and `/multiple-origins`, which list them. A
new package, `internal/bogon`, tells which prefixes and AS numbers are
reserved. The server works the figures out once a minute (closes #154)
- 2026-10-27: The per-peer route tables are gone, with the code that wrote,
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default,
and `TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: the home page has cards with figures of the routing table: the
newest and oldest kept route, the newest and oldest updated AS, the AS with
the most prefixes, the ASes with a single prefix, and fifteen well-known
networks. Two cards count the bogon announcements and the prefixes with more
than one origin AS, and link to the new pages `/bogons` and
`/multiple-origins` that list them. A new package, `internal/bogon`, tells
which prefixes and AS numbers are reserved. The server works the figures out
once a minute (closes #154)
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
counted and read them. The prefix handler applies each update to the routing
table in memory. It writes the table's changes every second. It also stores
each new prefix with its first-seen time. The image's memory limits fit a full
routing table of about 1 GB. `GOMEMLIMIT` is 2.5 GiB. The writing connection's
page cache is 512 MiB. SQLite's soft and hard heap limits are 768 MiB and 1
GiB (closes #122)
- 2026-10-27: The pages, the API and the stats read every route from the routing
table in memory and writes the table's changes every second. It also stores
each new prefix with its first-seen time. The image's memory limits are set
for a full routing table of about 1 GB: `GOMEMLIMIT` is 2.5 GiB, the writing
connection's page cache 512 MiB, and SQLite's heap limits 768 MiB and 1 GiB
(closes #122)
- 2026-10-27: the pages, the API and the stats read every route from the routing
table in memory. For a prefix with several origin ASes, the IP lookup gives
the one most peers carry. Its `last_seen` is the newest kept route's time. The
prefix page and API list every peer that carries the prefix. Only the kept
routes show an AS path, a next hop and a time. The stats count the origins
whose kept routes were all withdrawn, in `origins_without_kept_route` (part of
prefix page and API list every peer that carries the prefix, with an AS path,
next hop and time only for the kept routes. The stats count the origins whose
kept routes were all withdrawn, in `origins_without_kept_route` (part of #122)
- 2026-10-27: the prefix, AS and peer handlers make a timed write on every tick
of their timers, every 1, 2 and 2 seconds. They used to skip every other tick.
A full batch's write restarts the timer, so the next timed write comes one
interval after that write began (closes #192)
- 2026-10-27: once the stop deadline has passed, the AS, peer and peering
handlers start no write either. A full batch or a tick of the timer that
waited while a write ran past the deadline could still start one. The flush
goroutines of the prefix, AS and peer handlers now run the same code (closes
#189)
- 2026-10-27: once the stop deadline has passed, the prefix handler starts no
flush. A batch that filled while a write ran past the deadline could still be
written after it (closes #188)
- 2026-10-27: the prefix handler applies each update to the routing table in
memory, and writes the table's changes to the database every second. A write
takes at most 20,000 changed pairs, and the rest follow in the next writes. A
failed write is written again later. Startup reads the table before the
streamer connects. The pages still read the per-peer route tables (part of
#122)
- 2026-10-27: The prefix, AS and peer handlers make a timed write on every tick
of their timers. The prefix handler's timer ticks every second. The other two
tick every two seconds. They used to skip every other tick. A full batch's
write restarts the timer. So the next timed write comes one interval after
that write began (closes #192)
- 2026-10-27: Once the stop deadline has passed, the AS, peer and peering
handlers start no write either. Before, a full batch or a timer tick could
wait while a write ran past the deadline. Either could then start one more
write. The flush goroutines of the prefix, AS and peer handlers now run the
same code (closes #189)
- 2026-10-27: Once the stop deadline has passed, the prefix handler starts no
flush. Before, a batch that filled during a late write could still be written
after the deadline (closes #188)
- 2026-10-27: The prefix handler applies each update to the routing table in
memory. It writes the table's changes to the database every second. A write
takes at most 20,000 changed pairs of prefix and origin AS. The rest follow in
the next writes. A failed write is written again later. Startup reads the
table before the streamer connects. The pages still read the per-peer route
tables (part of #122)
- 2026-10-27: The database keeps a copy of the whole routing table in a new
- 2026-10-27: the database keeps a copy of the whole routing table in a new
`routes` table, one row per prefix and origin AS. The peers' numbers are in a
new `route_peers` table. One function writes the pairs that changed in memory.
Another reads the whole table back into memory at startup. Nothing uses them
yet (part of #122)
- 2026-10-27: A new package, `internal/routetable`, holds the whole routing
new `route_peers` table. One function writes the pairs that changed in memory,
and another reads the whole table back into memory at startup. Nothing uses
them yet (part of #122)
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
table in memory. For each prefix and origin AS it keeps the peers that carry
it, one bit per peer. It also keeps the two newest of their routes. Nothing
uses it yet (part of #122)
- 2026-10-27: Each result of the IP lookup lists the public blocklists the
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
(part of #122)
- 2026-10-27: each result of the IP lookup lists the public blocklists the
address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two
FireHOL lists, two Spamhaus DROP lists and the Tor exit list. RouteWatch
downloads each list on its own schedule and answers from copies in memory. The
copies are also kept in the state directory's `blocklists` directory. IP
lookup answers carry `Cache-Control`. An address with no route now has a
result too, without route fields. So the IP lookup no longer answers 404
(closes #158)
- 2026-10-27: The home page has an IP information API card. It shows the `curl`
command that looks up the visitor's own address at `/ip/{addr}`. It also shows
that command's live answer (closes #156)
- 2026-10-27: On the AS page, a click on the heading of the IPv4 or the IPv6
FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each
list on its own schedule and answers from copies in memory. The copies are
also kept in the state directory's `blocklists` directory. IP lookup answers
carry `Cache-Control`. An address with no live route now has a result too,
without route fields, so the IP lookup no longer answers 404 (closes #158)
- 2026-10-27: the home page has an IP information API card. It shows the `curl`
command that looks up the visitor's own address at `/ip/{addr}`, and that
command's live answer (closes #156)
- 2026-10-27: on the AS page, a click on the heading of the IPv4 or the IPv6
prefix list folds or unfolds the list. Both lists start open (closes #155)
- 2026-10-27: The AS page lists its IPv4 and its IPv6 prefixes by mask length,
shortest first. Prefixes of one mask length are listed by address. They used
to be sorted by address first, then by mask length (closes #153)
- 2026-10-27: The connection status on `/` and `/status` gets a spinner. It
turns one frame each time the stats show more messages than the time before.
After three refreshes with no new messages, the page shows "stale" in red
instead. Next to it is the time since the last fresh answer (closes #152)
- 2026-10-27: The WHOIS details of a Brazilian AS keep their accented letters.
LACNIC's answer for such an AS is NIC.br's answer with each accented letter
replaced. NIC.br is Brazil's registry. The details now come from NIC.br's own
server, `whois.registro.br`. When that query fails, the details from LACNIC's
answer are kept and a warning is logged. RouteWatch stops asking NIC.br for an
hour after NIC.br says RouteWatch is over its query limit (closes #174)
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
shortest first, and prefixes of one mask length by address. They used to be
listed by address (closes #153)
- 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame
each time the stats show more messages than the time before. A page that has
not seen more messages for three refreshes shows "stale" and the time since
then instead, in red (closes #152)
- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters.
LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each
accented letter replaced. The details now come from NIC.br's own server,
`whois.registro.br`. When that query fails, the details from LACNIC's answer
are kept and a warning is logged. After NIC.br answers that RouteWatch is over
its query limit, RouteWatch stops asking NIC.br for an hour (closes #174)
- 2026-10-27: `make asupdate` stops with an error when the download of `as.csv`
breaks off. The generator used to retry the failed read forever. A record that
does not parse is still logged and skipped (closes #177)
@@ -218,197 +155,182 @@ problem the run finds.
one AS. A failed download used to replace the list with an empty file. The
generator reads `as.csv` from `ipverse/as-metadata`, the source's new name
(closes #173)
- 2026-10-27: The WHOIS details of an AS at RIPE, APNIC or AFRINIC come only
- 2026-10-27: the WHOIS details of an AS at RIPE, APNIC or AFRINIC come only
from its `aut-num` object and the organisation object it names. RIPE and APNIC
start their answer with the `as-block` object of the AS's block. Its dates
were stored as the AS's (closes #168)
- 2026-10-27: AS names keep their accented letters. The AS list built into
RouteWatch is rebuilt from today's `as.csv`, which added a country code
column. That file no longer lists AS 0 or the documentation AS numbers. A
WHOIS answer that is not valid UTF-8 is decoded as Latin-1 before it is parsed
and stored. RIPE and APNIC send Latin-1 (closes #165)
- 2026-10-27: The Age column on the AS and prefix pages reads in words, such as
"5 minutes ago" or "2 weeks ago". It used to print Go durations such as
"360h0m0s ago". An age under a minute reads "now" or in seconds. An age over a
month reads in months or years instead of a date (closes #163)
- 2026-10-26: A write to a stored AS, peering or peer keeps the earlier of the
two first-seen times. It keeps the later of the two last-seen times. An old
update from a collector catching up on a backlog no longer moves last seen
back (closes #162)
- 2026-10-26: The status page shows "Route Churn/min". That is the number of
prefix announcements and withdrawals from all peers in the last full clock
minute. `/api/v1/stats` reports it as `route_churn_per_min` in `stream`. It
WHOIS answer that is not valid UTF-8 is decoded as Latin-1, which RIPE and
APNIC send, before it is parsed and stored (closes #165)
- 2026-10-27: the Age column on the AS and prefix pages reads in words, such as
"5 minutes ago" or "2 weeks ago", instead of Go durations such as "360h0m0s
ago". An age under a minute reads in seconds, and one over a month in months
or years instead of a date (closes #163)
- 2026-10-26: a write to a stored AS, peering or peer keeps the earlier of the
two first-seen times and the later of the two last-seen times. An old update
from a collector catching up on a backlog no longer moves last seen back
(closes #162)
- 2026-10-26: the status page shows "Route Churn/min", the number of prefix
announcements and withdrawals, from all peers, in the last full clock minute.
`/api/v1/stats` reports it as `route_churn_per_min` in `stream`, which
replaces the moving average `route_churn_per_sec`. The messages and updates
per second on `/` and `/status` are whole numbers (closes #160)
- 2026-10-26: The WHOIS fetcher backs off more slowly. A failure makes its wait
- 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait
1.5 times as long, up to one minute instead of five. Its queries to RIPE,
APNIC and AFRINIC leave out person and role objects. RIPE counts those objects
against a daily limit per address. A refused query is logged with the RIR's
error line (closes #159)
- 2026-10-26: The home page's AS name search works. It lists the ASes whose
handle or description holds the text, as links to their pages. The new
`/api/v1/search/as` answers it. The search ignores case. When the database has
no record of an AS, its page takes the handle and description from the AS list
built into RouteWatch. It still lists the AS's prefixes (closes #151)
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers.
README "Database Schema" matches `schema.sql`. The rest of the README matches
the code. This file is brought up to date (closes #72)
- 2026-10-19: Route writes are cheaper. The live route and prefix tables lose
their random UUID keys. The prefix tables lose their duplicate index. A route
update sets only the columns that change. Every write goes through one
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
a daily limit per address. A refused query is logged with the RIR's error line
(closes #159)
- 2026-10-26: the home page's AS name search lists the ASes whose handle or
description holds the text, as links to their pages, from the new
`/api/v1/search/as`; the search ignores case. When the database has no record
of an AS, its page takes the handle and description from the AS list built
into RouteWatch and still lists the AS's prefixes (closes #151)
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers,
"Database Schema" matches `schema.sql`, and the rest of the README matches the
code; this file brought up to date (closes #72)
- 2026-10-19: route writes are cheaper: the live route and prefix tables lose
their random UUID keys and the prefix tables their duplicate index, a route
update sets only the columns that change, and every write goes through one
connection with a page cache of 1 GiB (README "Memory"). A benchmark writes
the prefix handler's largest batch faster than the feed sends it (closes #96)
- 2026-10-19: The checkpoint of the WAL (SQLite's write-ahead log), the
incremental vacuum and `ANALYZE` take the database lock that every batch write
holds. So a vacuum no longer fails with "database is locked". An `ANALYZE` no
longer makes a batch write lose its updates. `ANALYZE` runs as
`PRAGMA optimize` with an analysis limit (closes #94)
- 2026-10-19: Shutdown stops the streamer first. Each handler then writes
everything left on its queue before it stops. So the updates waiting there are
- 2026-10-19: the WAL checkpoint, the incremental vacuum and ANALYZE take the
database lock every batch write holds, so a vacuum no longer fails with
"database is locked" and an ANALYZE no longer makes a batch write lose its
updates; ANALYZE runs as `PRAGMA optimize` with an analysis limit (closes #94)
- 2026-10-19: shutdown stops the streamer first, and each handler writes
everything left on its queue before it stops, so the updates waiting there are
no longer lost (closes #69)
- 2026-10-19: A path that ends in an AS set of several members has origin AS 0,
which means no known origin. It used to have the set's last member as its
origin. The set adds no peerings. The pages show such a route as having no
known origin (closes #75)
- 2026-10-19: a path that ends in an AS set of several members has origin AS 0,
no known origin, instead of the set's last member, and the set adds no
peerings; the pages show such a route as having no known origin (closes #75)
- 2026-10-19: `bgp_peer_count` drops to 0 when the last peer goes down (closes
#92)
- 2026-10-19: `bgp_peer_count` counts every peer the feed has heard from. It
used to count only the peers whose session opened since startup. It drops a
peer whose session goes down (closes #74)
- 2026-10-19: The shared files are re-vendored from `sneak/prompts` at
`0653579`. The linter is `golangci-lint` v2.14.0 with its configuration. Lint
and test are `Dockerfile` phases that keep Go's and `golangci-lint`'s caches.
`make fmt` also formats the markdown with `prettier`. The agent guidance moves
to `AGENTS.md`. The code is fixed for what the new linter configuration finds
(closes #52)
- 2026-10-19: The streamer reads feed lines up to 1 MiB. A line over 64 KiB used
to end the connection (closes #76)
- 2026-10-19: A peer's live routes are removed when its BGP session goes down.
`STATE` messages are never dropped when a queue is full. One that finds its
- 2026-10-19: `bgp_peer_count` counts every peer the feed has heard from, not
only those whose session opened since startup, and drops a peer whose session
goes down (closes #74)
- 2026-10-19: the shared files are re-vendored from `sneak/prompts` at
`0653579`: golangci-lint v2.14.0 and its configuration, lint and test as
`Dockerfile` phases that keep Go's and golangci-lint's caches, markdown
formatted with prettier, agent guidance in `AGENTS.md`; the code is fixed for
what the new linter configuration finds (closes #52)
- 2026-10-19: the streamer reads feed lines up to 1 MiB, so a line over 64 KiB
no longer ends the connection (closes #76)
- 2026-10-19: a peer's live routes are removed when its BGP session goes down;
`STATE` messages are never dropped for backpressure, and one that finds its
queue full waits for room ahead of the handler's other messages (closes #56)
- 2026-10-19: The prefix-length pages pick their sample with a bounded number of
index lookups. They used to read every route of that length. They list each
- 2026-10-19: the prefix-length pages pick their sample with a bounded number of
index lookups instead of reading every route of that length, and list each
prefix once (closes #66)
- 2026-10-19: A request past its time limit answers 504 instead of 408.
- 2026-10-19: a request past its time limit answers 504 instead of 408, and
`/api/v1/stats` answers 504 instead of 500 when its read runs out of time
(closes #70)
- 2026-10-19: The AS, peer and prefix handlers write each batch outside the lock
their message handling needs. So messages keep coming off their queues during
a write. Meanwhile the next batch grows to at most ten batch sizes (closes
- 2026-10-19: the AS, peer and prefix handlers write each batch outside the lock
their message handling needs, so messages keep coming off their queues during
a write; meanwhile the next batch grows to at most ten batch sizes (closes
#65)
- 2026-10-19: A live route is identified by prefix and peer. So an announcement
with a new origin AS replaces the peer's route. A withdrawal removes it
- 2026-10-19: a live route is identified by prefix and peer, so an announcement
with a new origin AS replaces the peer's route and a withdrawal removes it
whatever its origin AS (closes #55)
- 2026-10-14: The streamer reconnects when RIS Live ends the stream or sends
nothing for 60 seconds. The health check reports the feed disconnected
- 2026-10-14: the streamer reconnects when RIS Live ends the stream or sends
nothing for 60 seconds, and the health check reports the feed disconnected
meanwhile (closes #53)
- 2026-10-14: The prefix page takes the names of the origin ASes from the prefix
query. It used to read every route of each origin AS (closes #67)
- 2026-10-14: The daemon exits with status 1 when its web server cannot listen
on its port. It used to run on without a web server (closes #73)
- 2026-10-14: The prefix pages parse the prefix before looking it up. One that
does not parse answers 400. Any valid way of writing a prefix finds its routes
(closes #71)
- 2026-10-14: The IP lookup ignores routes shorter than /8 for IPv4 and /16 for
IPv6. So a default route is no longer named as an address's owner (closes #57)
- 2026-10-14: A prefix batch keeps each peer's last update for a prefix. It used
to keep one update per prefix across all peers (closes #54)
- 2026-10-14: the prefix page takes the names of the origin ASes from the prefix
query instead of reading every route of each origin AS (closes #67)
- 2026-10-14: the daemon exits with status 1 when its web server cannot listen
on its port, instead of running on without it (closes #73)
- 2026-10-14: the prefix pages parse the prefix before looking it up: one that
does not parse answers 400, and any valid way of writing a prefix finds its
routes (closes #71)
- 2026-10-14: the IP lookup ignores routes shorter than /8 for IPv4 and /16 for
IPv6, so a default route is no longer named as an address's owner (closes #57)
- 2026-10-14: a prefix batch keeps each peer's last update for a prefix, instead
of one update per prefix across all peers (closes #54)
- 2026-10-14: `reconnect_count` in `/api/v1/stats` counts every reconnect
(closes #68)
- 2026-10-03: `/api/v1/stats` serves the prefix distribution from memory. The
distribution is seeded at startup and adjusted on every live-route write. So a
request no longer reads every live route (closes #30)
- 2026-10-03: A new database is created with `auto_vacuum` set to incremental.
The setting goes in the connection string, so it is set before the file is
first written. Each periodic incremental vacuum now returns up to 1000 free
pages. The late `PRAGMA auto_vacuum` in `Initialize`, which SQLite ignored, is
gone (closes #43)
- 2026-10-03: Looking up an IP address no longer reads every IPv6 route. Both
- 2026-10-03: `/api/v1/stats` serves the prefix distribution from memory, seeded
at startup and adjusted on every live-route write, so a request no longer
reads every live route (closes #30)
- 2026-10-03: a new database is created with `auto_vacuum` set to incremental,
through the connection string so it is set before the file is first written,
and each periodic incremental vacuum now returns up to 1000 free pages; the
late `PRAGMA auto_vacuum` in `Initialize`, which SQLite ignored, is gone
(closes #43)
- 2026-10-03: looking up an IP address no longer reads every IPv6 route: both
families find the most specific live route with at most 33 or 129 lookups on
the prefix index. The IPv4 range columns are gone. Prefixes from the feed are
stored in one text form. So an IPv6 withdrawal, which the feed sends
the prefix index, and the IPv4 range columns are gone. Prefixes from the feed
are stored in one text form, so an IPv6 withdrawal, which the feed sends
uncompressed, now removes its route (closes #48)
- 2026-10-02: A plain `docker build .` stamps the commit's tag or short commit
into the page footer. It used to stamp `unknown`. The value comes from
`git describe --tags --always`. `.dockerignore` sends `.git` without
`.git/config`. A `VERSION` build argument takes precedence. `make build`
stamps the same value (closes #46)
- 2026-09-29: The entrypoint creates the data directory if it is missing. It
stops the start if a step fails. README "Running under upaas" no longer asks
- 2026-10-02: a plain `docker build .` stamps the commit's tag or short commit
(`git describe --tags --always`) into the page footer instead of `unknown`:
`.dockerignore` sends `.git` without `.git/config`, a `VERSION` build argument
takes precedence, and `make build` stamps the same value (closes #46)
- 2026-09-29: the entrypoint creates the data directory if it is missing and
stops the start if a step fails; README "Running under upaas" no longer asks
for the host directory to be created first (closes #42)
- 2026-09-29: `.dockerignore` keeps `.git`, local build output, local databases
and `.env` out of the Docker build context. So they stay out of the source
archive in the image (closes #39)
- 2026-09-29: The README's first line names the MIT licence and the author. The
License section says MIT and links `LICENSE`. An Author section is added. This
file is brought up to date (closes #38)
- 2026-09-29: `LICENSE` is added, with the MIT licence (closes #1)
- 2026-09-28: Stopping the daemon while the feed is flowing no longer panics
with "send on closed channel". The read loop checks for a stop just before
handing a message to the handler queues. A second `Stop` no longer closes the
queues again (closes #34)
and `.env` out of the Docker build context, and so out of the source archive
in the image (closes #39)
- 2026-09-29: README first line names the MIT license and the author; the
License section now says MIT and links `LICENSE`, and an Author section was
added; this file brought up to date (closes #38)
- 2026-09-29: MIT `LICENSE` (closes #1)
- 2026-09-28: stopping the daemon while the feed is flowing no longer panics
with "send on closed channel": the read loop checks for a stop just before
handing a message to the handler queues, and a second `Stop` no longer closes
the queues again (closes #34)
- 2026-09-28: `docker stop` no longer kills the daemon 2 seconds after the stop
signal. The entrypoint switches to the `routewatch` user with `setpriv`
instead of `runuser`. So the daemon receives the signal itself. It gets the
signal: the entrypoint switches to the `routewatch` user with `setpriv`
instead of `runuser`, so the daemon receives the signal itself and gets the
whole wait `docker stop` allows, up to its own 60-second limit (closes #33)
- 2026-09-28: RouteWatch is ready to run under upaas. A set but invalid `PORT`,
`XDG_DATA_HOME` or `MALLOC_ARENA_MAX` stops the start. The health check
follows `PORT`. README gains a "Running under upaas" section (closes #31)
- 2026-09-22: The database statistics are kept in memory. The counts are seeded
at startup and adjusted on every write. The oldest and newest route times come
from a lookup at each end of an index. `/api/v1/stats` no longer scans the
tables (closes #27)
- 2026-09-21: Batch writes take the write lock when their transaction begins
(`_txlock=immediate`). So they wait out a WAL checkpoint instead of failing
- 2026-09-28: ready to run under upaas: a set but invalid `PORT`,
`XDG_DATA_HOME` or `MALLOC_ARENA_MAX` stops the start, the health check
follows `PORT`, README "Running under upaas" section (closes #31)
- 2026-09-22: realtime in-memory database statistics: counts seeded at startup
and adjusted on every write, oldest/newest route timestamps via index-end
lookups; `/api/v1/stats` no longer scans the tables (closes #27)
- 2026-09-21: batch writes take the write lock when their transaction begins
(`_txlock=immediate`), so they wait out a WAL checkpoint instead of failing
with "database is locked" (closes #25)
- 2026-09-21: `MALLOC_ARENA_MAX=2` in the image caps the number of glibc malloc
arenas. So memory outside the Go runtime no longer grows with the core count
(closes #23)
- 2026-09-21: The image sets `GOMEMLIMIT=1536MiB`. README "Memory" gives the
- 2026-09-21: `MALLOC_ARENA_MAX=2` in the image caps glibc malloc arenas, so
memory outside the Go runtime no longer grows with the core count (closes #23)
- 2026-09-21: `GOMEMLIMIT=1536MiB` in the image; README Memory section with the
memory budget and the 5 GiB container limit (closes #13)
- 2026-09-21: The four handler queues hold at most 20,000 messages each, down
- 2026-09-21: the four handler queues hold at most 20,000 messages each, down
from 100,000 (closes #11)
- 2026-09-21: Two goroutine leaks are fixed. The stats handlers leaked after a
timeout. The streamer's tickers leaked on every reconnect (closes #12)
- 2026-09-21: Parsed RIS messages no longer keep the unused `Community` and
- 2026-09-21: two goroutine leaks fixed: the stats handlers after a timeout and
the streamer's tickers on every reconnect (closes #12)
- 2026-09-21: parsed RIS messages no longer keep the unused `Community` and
`Raw` fields (closes #9)
- 2026-09-21: `.editorconfig` is added. A Gitea workflow runs `script/cibuild`
- 2026-09-21: `.editorconfig`, and a Gitea workflow that runs `script/cibuild`
on every push (closes #14)
- 2026-09-21: The peering handler's AS-path map holds at most 500,000 paths.
Every 30 seconds it is swapped for an empty one instead of copied (closes #10)
- 2026-09-21: SQLite's memory is bounded across the whole connection pool. Each
connection has a 64 MiB page cache. The heap limits are 1 GiB soft and 1.5 GiB
hard (closes #8)
- 2026-09-21: The Docker build runs the format check, the linter and the tests.
Linting runs in a separate stage on a `golangci-lint` image pinned by digest
(closes #5)
- 2026-09-21: `make test` skips the live feed test, with `-short`. So
- 2026-09-21: the peering handler's AS-path map holds at most 500,000 paths and
is swapped for an empty one every 30 seconds instead of copied (closes #10)
- 2026-09-21: SQLite memory bounded across the whole connection pool: a 64 MiB
page cache on each connection, 1 GiB soft and 1.5 GiB hard heap limits (closes
#8)
- 2026-09-21: the Docker build runs the format check, the linter and the tests,
linting in a separate stage on a golangci-lint image pinned by digest (closes
#5)
- 2026-09-21: `make test` skips the live-network feed test (`-short`), so
`make check` no longer depends on the network (closes #2)
- 2026-07-07: The scripts-to-rule-them-all layout is adopted. `script/` holds
the entrypoints. The `Makefile` targets `check`, `test`, `lint`, `fmt` and
`fmt-check` call them. So do `bootstrap`, `setup`, `docker` and `hooks`.
README gains an "Entrypoints" section
- 2026-02-22: The repo is brought in line with the repo policies. The required
policy files are added. `.gitignore` is updated. The `Makefile` gains
`fmt-check`, `check`, `docker` and `hooks` targets. The Go code is formatted
with `gofmt`. The work was on the branch `repo-policies-compliance`, unmerged
when this entry was written
- 2026-01-01: The WAL is checkpointed every 5 seconds, and at startup with a log
line. Checkpoints run in `TRUNCATE` mode. This fixed slow queries
- 2025-12-31: The pages get a navigation bar and a home page with a search. The
status page shows the oldest and newest route times. The WHOIS stats query
handles `NULL`. The container runs as the `routewatch` user with the right
state directory
- 2025-12-30: HTTP requests are logged as structured log lines. The HTTP
timeouts are longer. Prefix URLs with a slash in them route correctly. The
status page gets more metrics and a footer
- 2025-12-29: The `Dockerfile` builds in stages and puts a source archive in the
image. SQLite reclaims space with incremental vacuum, which does not block
- 2025-07-27: The first commit: RouteWatch, a BGP stream monitor
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-22: repo policy compliance: required policy files, .gitignore update,
Makefile fmt-check/check/docker/hooks targets, gofmt pass
(repo-policies-compliance, unmerged)
- 2026-01-01: WAL checkpointing: periodic (5s), on startup with logging,
TRUNCATE mode; fixed slow queries
- 2025-12-31: status page: navbar, home page with search, oldest/newest route
timestamps, NULL handling in WHOIS stats; container runs as routewatch user
with proper state dir
- 2025-12-30: structured HTTP request logging, increased timeouts, CIDR prefix
URL routing fixes, status page metrics and footer
- 2025-12-29: Dockerfile multi-stage build with source archive; SQLite
incremental vacuum for non-blocking space reclamation
- 2025-07-27: initial RouteWatch BGP stream monitor
# Future Steps
- Finish https://git.eeqj.de/sneak/routewatch/pulls/210 as sneak's review
directs. It merges once a review passes. Of the two PRs for
https://git.eeqj.de/sneak/routewatch/issues/157, the one that merges last
closes the issue.
None.
+4
View File
@@ -10,6 +10,10 @@ import (
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// walkBatch is how many prefixes the walk over the whole table reads in one
// hold of the read lock. The updates waiting for the lock get it in between.
const walkBatch = 10000
// Overview holds figures about the whole table. Working them out takes a walk
// over every prefix, so a caller asks for them now and then and keeps them.
type Overview struct {
+2 -28
View File
@@ -27,11 +27,6 @@ const (
// kept routes in order: each of its nodes holds up to 63 times.
keptTimesDegree = 32
// walkBatch is how many prefixes a walk over the whole table visits in
// one hold of the lock. Overview and PeerDown each walk the whole table.
// Callers waiting for the lock get it between batches.
walkBatch = 10000
ipv4Bits = 32
ipv6Bits = 128
@@ -206,13 +201,8 @@ func (t *Table) Withdraw(prefix netip.Prefix, peer string) {
}
// PeerDown applies a peer's session going down: the peer leaves every peer
// set, and every route of the peer is dropped. When the peer carries any
// prefix, PeerDown visits every prefix in the table, in batches of walkBatch.
// After each batch, it lets the table's lock go for as long as it held the
// lock for the batch. During a walk over many batches, reads and updates can
// then take the lock about half of the time. An announcement by the peer
// applied during the walk may be dropped too. So the caller applies the peer's
// later updates only once PeerDown has returned.
// set, and every route of it is dropped. When the peer carries any prefix, it
// visits every prefix in the table.
func (t *Table) PeerDown(peer string) {
t.mu.Lock()
defer t.mu.Unlock()
@@ -222,9 +212,6 @@ func (t *Table) PeerDown(peer string) {
return
}
visited := 0
batchStart := time.Now()
for _, f := range []*family{&t.ipv4, &t.ipv6} {
for _, prefixes := range f.byLength {
for prefix, origins := range prefixes {
@@ -232,19 +219,6 @@ func (t *Table) PeerDown(peer string) {
if o != nil {
t.leave(prefix, o, number)
}
// Go allows the map to change before the next step of
// the range over it.
visited++
if visited%walkBatch == 0 {
held := time.Since(batchStart)
t.mu.Unlock()
time.Sleep(held)
t.mu.Lock()
batchStart = time.Now()
}
}
}
}
@@ -1,69 +0,0 @@
package routetable
import (
"net/netip"
"runtime"
"testing"
"time"
)
// TestPeerDownLetsReadsIn reads the table's counts again and again while a peer
// that carries more than two batches of prefixes goes down. PeerDown lets the
// lock go after every walkBatch prefixes, so a read must get in while the peer
// still carries some of its prefixes but no longer all.
func TestPeerDownLetsReadsIn(t *testing.T) {
t.Parallel()
const (
asn = 1000
peer = "192.0.2.1"
// prefixCount is more prefixes than two of walkBatch.
prefixCount = 2*walkBatch + 1
)
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
table := New(Params{})
addr := netip.MustParseAddr("1.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
addr = addr.Next()
}
// Hold a read lock until PeerDown waits for the lock, so that the first
// read below starts once PeerDown holds it. TryRLock fails once a writer
// waits.
table.mu.RLock()
peerDownDone := make(chan struct{})
go func() {
defer close(peerDownDone)
table.PeerDown(peer)
}()
for table.mu.TryRLock() {
table.mu.RUnlock()
runtime.Gosched()
}
table.mu.RUnlock()
for {
routes := table.Counts().IPv4Routes
if routes > 0 && routes < prefixCount {
break
}
select {
case <-peerDownDone:
t.Fatal("no read got in while PeerDown ran")
default:
}
}
<-peerDownDone
}
@@ -644,8 +644,9 @@ func TestShutdownDoesNotWaitForABlockedWrite(t *testing.T) {
}
rw.dbHandler = NewASHandler(store, log)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
startBlockedWrite(t, blocking, rw.dbHandler,
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}})
[]*ristypes.RISMessage{{Path: fill}})
ctx, cancel := context.WithTimeout(t.Context(), deadline)
defer cancel()
+97 -47
View File
@@ -18,34 +18,43 @@ const (
// (asnBatchTimeout), so a queue smaller than asnBatchSize is fine.
asHandlerQueueSize = 20000
// asnBatchSize is the number of ASes in a full batch, which wakes the
// flush goroutine to write it.
// asnBatchSize is the number of ASN operations to batch together
asnBatchSize = 30000
// asnBatchMaxSize is the most ASN operations the batch holds while the
// flush goroutine writes the previous one: ten batches, about 10 MiB
// (README "Memory"). At this size HandleMessage waits until the flush
// goroutine takes the batch.
asnBatchMaxSize = 300000
// asnBatchTimeout is the maximum time to wait before flushing a batch
// DO NOT reduce this timeout - larger batches are more efficient
asnBatchTimeout = 2 * time.Second
)
// ASHandler records every AS in the AS paths of BGP UPDATE messages. Its batch
// holds the earliest and the latest time of each AS's updates. The flush
// goroutine writes the batch to the database when it holds asnBatchSize ASes,
// and every asnBatchTimeout.
// ASHandler processes Autonomous System Number (ASN) information extracted from
// BGP UPDATE messages. It uses batched database operations to efficiently store
// ASN data, collecting operations into batches that are flushed either when the
// batch reaches a size threshold or after a timeout period.
type ASHandler struct {
db database.Store
logger *logger.Logger
// Batching. mu guards batch and writing only. The flush goroutine takes
// the batch under mu and writes it after releasing mu. So HandleMessage
// never waits for a database write.
mu sync.Mutex
// batch holds the earliest and the latest time of the updates for each AS
// since the flush goroutine last took it. It holds one entry per AS,
// however many updates arrive.
batch map[int]database.SeenTimes
// writing is how many ASes the flush goroutine took from batch and has
// not finished writing.
writing int
// batchMaxSize is asnBatchMaxSize. Only HandleMessage reads it, so a test
// may lower it before it hands the handler a message.
batchMaxSize int
// Batching. mu guards batch and writing only: the flush goroutine takes
// the batch under mu and writes it after releasing mu, so HandleMessage
// waits for a database write only while batch is at batchMaxSize.
mu sync.Mutex
batch []asnOp
// writing is how many operations the flush goroutine took from batch
// and has not finished writing.
writing int
// batchTaken is signalled when the flush goroutine takes batch; its L is
// &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
@@ -53,10 +62,16 @@ type ASHandler struct {
done chan struct{} // closed when the flush goroutine returns
}
type asnOp struct {
number int
timestamp time.Time
}
// NewASHandler creates and returns a new ASHandler instance. It initializes
// the batching system and starts a background goroutine that periodically
// writes the collected ASes to the database. The caller must call Stop when
// finished, to write the pending ASes and end the background goroutine.
// flushes accumulated ASN operations to the database. The caller must call
// Stop when finished to ensure all pending operations are flushed and the
// background goroutine is terminated.
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
return newASHandler(db, logger, asnBatchTimeout)
}
@@ -68,13 +83,15 @@ func newASHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *ASHandler {
h := &ASHandler{
db: db,
logger: logger,
batch: make(map[int]database.SeenTimes),
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
db: db,
logger: logger,
batchMaxSize: asnBatchMaxSize,
batch: make([]asnOp, 0, asnBatchSize),
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
@@ -98,25 +115,48 @@ func (h *ASHandler) QueueCapacity() int {
return asHandlerQueueSize
}
// HandleMessage adds the time of msg to the batch entry of each AS in its AS
// path, the origin AS and the members of an AS set included. When the batch
// holds asnBatchSize ASes, HandleMessage wakes the flush goroutine to write it.
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from
// the AS path and queuing them for batch insertion into the database. The
// origin ASN and all transit ASNs are recorded with their associated
// timestamps. When the batch reaches the configured size threshold, the flush
// goroutine is woken to write it.
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
// Use the pre-parsed timestamp
timestamp := msg.ParsedTimestamp
originASN := msg.Path.Origin()
h.mu.Lock()
defer h.mu.Unlock()
// The origin AS is the last AS of the sequence, so the loop adds it too.
// While the batch is at its maximum size, wait until the flush goroutine
// takes it. Meanwhile the handler takes nothing off its queue, which
// fills, and the streamer drops messages for it.
for len(h.batch) >= h.batchMaxSize {
h.batchTaken.Wait()
}
// Queue origin ASN operation
if originASN > 0 {
h.batch = append(h.batch, asnOp{
number: originASN,
timestamp: timestamp,
})
}
// Also track all ASNs in the path, the members of an AS set included
for _, asn := range slices.Concat(msg.Path.Sequence, msg.Path.Set) {
if asn > 0 {
seen := h.batch[asn]
seen.Add(msg.ParsedTimestamp)
h.batch[asn] = seen
h.batch = append(h.batch, asnOp{
number: asn,
timestamp: timestamp,
})
}
}
// A full batch wakes the flush goroutine. If it is writing, the wake-up
// waits in batchFullCh and the batch keeps taking updates until the write
// ends.
// waits in batchFullCh and the batch keeps growing until the write ends
// or the batch reaches batchMaxSize.
if len(h.batch) >= asnBatchSize {
select {
case h.batchFullCh <- struct{}{}:
@@ -125,11 +165,10 @@ func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// Stop stops the flush goroutine and waits for it until ctx ends. The flush
// goroutine writes the batch before it returns, unless ctx has ended. A write
// still running when the process exits is lost. Stop logs how many entries are
// not written, in the batch being written and in the next batch. An AS in both
// batches counts twice.
// Stop stops the flush goroutine, which writes the operations the handler holds
// before it returns, and waits for it until ctx ends. Once ctx has ended the
// flush goroutine skips that last write, and a write still running is lost
// when the process exits, so Stop logs how many operations are not written.
func (h *ASHandler) Stop(ctx context.Context) {
h.stopCh <- ctx
@@ -144,14 +183,14 @@ func (h *ASHandler) Stop(ctx context.Context) {
if unwritten > 0 {
h.logger.Error("Stop deadline passed before the AS handler wrote "+
"its batches", "batch_entries", unwritten)
"its updates", "updates", unwritten)
}
}
// takeBatch returns the pending ASes and leaves an empty batch in their place
// for HandleMessage to fill. They count in writing until flushBatch has written
// them.
func (h *ASHandler) takeBatch() map[int]database.SeenTimes {
// takeBatch returns the pending operations and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
func (h *ASHandler) takeBatch() []asnOp {
h.mu.Lock()
defer h.mu.Unlock()
@@ -161,7 +200,8 @@ func (h *ASHandler) takeBatch() map[int]database.SeenTimes {
batch := h.batch
h.writing = len(batch)
h.batch = make(map[int]database.SeenTimes)
h.batch = make([]asnOp, 0, asnBatchSize)
h.batchTaken.Broadcast()
return batch
}
@@ -174,11 +214,21 @@ func (h *ASHandler) flushBatch() {
return
}
// The earliest and the latest time the batch saw each AS. The batch holds
// several updates for an AS, from many peers, so a new AS is stored first
// seen at the earliest of them and last seen at the latest.
asns := make(map[int]database.SeenTimes)
for _, op := range batch {
seen := asns[op.number]
seen.Add(op.timestamp)
asns[op.number] = seen
}
// Process all ASNs in a single batch transaction
err := h.db.GetOrCreateASNBatch(batch)
err := h.db.GetOrCreateASNBatch(asns)
if err != nil {
h.logger.Error("Failed to process ASN batch",
"error", err, "count", len(batch))
"error", err, "count", len(asns))
}
h.mu.Lock()
+7 -24
View File
@@ -5,43 +5,28 @@ import (
"slices"
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// pathOfASes returns an AS path of n different ASes: AS 1 to AS n. With n up to
// asnBatchSize, none of them is testASNA to testASND.
func pathOfASes(n int) ristypes.ASPath {
sequence := make([]int, n)
for i := range sequence {
sequence[i] = i + 1
}
return ristypes.ASPath{Sequence: sequence}
}
// TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS handles two updates
// whose path carries an AS the database does not hold yet, in one batch into a
// real database. The second is 5 seconds after the first, and the handler gets
// it first. The AS is stored first seen at the time of the first update and
// last seen at the time of the second.
// whose path carries an AS the database does not hold yet, the second 5
// seconds after the first, in one batch into a real database. The AS is stored
// first seen at the time of the first update and last seen at the time of the
// second.
func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
// Built without NewASHandler's flush goroutine, so the test flushes itself.
h := &ASHandler{
db: db, logger: logger.New(), batch: make(map[int]database.SeenTimes),
}
h := &ASHandler{db: db, logger: logger.New(), batchMaxSize: asnBatchMaxSize}
first := peerAnnouncement(testPeerIP, testV4Prefix)
second := peerAnnouncement(testPeerIP, testV4Prefix)
second.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter)
h.HandleMessage(second)
h.HandleMessage(first)
h.HandleMessage(second)
h.flushBatch()
asn, _, err := db.GetAS(t.Context(), testASNA)
@@ -69,9 +54,7 @@ func TestASHandlerRecordsNothingFromAPathWithASZero(t *testing.T) {
store := newMockStore()
// Built without NewASHandler's flush goroutine, so the test flushes itself.
h := &ASHandler{
db: store, logger: logger.New(), batch: make(map[int]database.SeenTimes),
}
h := &ASHandler{db: store, logger: logger.New(), batchMaxSize: asnBatchMaxSize}
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = decodePath(t, testASNA, 0, testASNB)
+5 -31
View File
@@ -187,33 +187,25 @@ func (f *ASNFetcher) recordSuccess() {
f.statsMu.Unlock()
}
// lengthenInterval makes the interval 1.5 times as long, up to maxInterval.
// A failed lookup and a skipped one both lengthen it.
func (f *ASNFetcher) lengthenInterval() {
// recordFailure increases the interval on failed fetch using exponential backoff.
func (f *ASNFetcher) recordFailure() {
f.intervalMu.Lock()
defer f.intervalMu.Unlock()
f.consecutiveFails++
// Exponential backoff: multiply by 1.5, capped at max
newInterval := min(
f.currentInterval*backoffNumerator/backoffDenominator, maxInterval)
if newInterval != f.currentInterval {
f.logger.Debug("Increased fetch interval",
f.logger.Debug("Increased fetch interval due to failure",
"old_interval", f.currentInterval,
"new_interval", newInterval,
"consecutive_failures", f.consecutiveFails,
)
f.currentInterval = newInterval
}
}
// recordFailure counts a failed fetch and lengthens the interval.
func (f *ASNFetcher) recordFailure() {
f.intervalMu.Lock()
f.consecutiveFails++
f.intervalMu.Unlock()
f.lengthenInterval()
// Record error time for stats, dropping those older than statsWindow
f.statsMu.Lock()
now := f.now()
@@ -295,16 +287,6 @@ func (f *ASNFetcher) fetchNextStale(ctx context.Context) {
// so the AS stays stale. An AS IANA refers to no RIR is stored with no
// WHOIS data, so it is not looked up again until it is stale; that counts
// as neither a success nor a failure.
//
// The WHOIS client pauses an RIR's or NIC.br's server for whois.PauseTime
// after the server refuses a query. Lookups at the other servers go on. IANA
// is never paused, so a refusal by IANA is an ordinary failure. A lookup
// whose RIR is paused fails with whois.ErrPaused and is skipped. A skipped
// lookup leaves the AS stale and is not counted as a failure. Like a
// failure, a skipped lookup lengthens the interval. Each lookup asks IANA
// before it finds its RIR paused, so with a one-second interval the fetcher
// would ask IANA every second. The lookups that QueueImmediate asks for also
// run through fetchAndUpdate.
func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
@@ -325,14 +307,6 @@ func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
return false
}
if errors.Is(err, whois.ErrPaused) {
f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused",
"asn", asn, "error", err)
f.lengthenInterval()
return false
}
if err != nil {
f.logger.Error("WHOIS lookup failed", "asn", asn, "error", err)
f.recordFailure()
@@ -13,7 +13,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/whois"
)
// The WHOIS servers a lookup asks: IANA first, then the RIR IANA refers it
@@ -132,151 +131,6 @@ func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
}
}
// TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses
// the query. The test sets the WHOIS client's clock. The next lookup comes a
// second before whois.PauseTime has passed. It sends RIPE nothing and leaves
// the AS never looked up. It counts as neither a success nor a failure. A
// lookup at whois.PauseTime asks RIPE again.
func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
t.Parallel()
f, db := newFetcherWithWHOISAnswers(t, map[string]string{
ianaWHOISServer: ianaRefersToRIPE,
ripeWHOISServer: ripeAccessDenied,
})
ripeAsks := 0
dial := f.whoisClient.DialContext
f.whoisClient.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ripeWHOISServer {
ripeAsks++
}
return dial(ctx, network, address)
}
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
now = start.Add(whois.PauseTime - time.Second)
f.fetchAndUpdate(t.Context(), testASNA)
if ripeAsks != 1 {
t.Errorf("RIPE was asked %d times during its pause, want 1", ripeAsks)
}
stats, err := db.GetWHOISStats(t.Context(), whoisStaleThreshold)
if err != nil {
t.Fatalf("GetWHOISStats: %v", err)
}
if stats.NeverFetched != 1 {
t.Errorf("never looked up = %d, want 1", stats.NeverFetched)
}
got := f.GetStats()
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 ||
got.SuccessesLastHour != 0 {
t.Errorf("%d failures in a row, %d errors, %d successes; want 1, 1, 0",
got.ConsecutiveFails, got.ErrorsLastHour, got.SuccessesLastHour)
}
now = start.Add(whois.PauseTime)
f.fetchAndUpdate(t.Context(), testASNA)
if ripeAsks != 2 {
t.Errorf("RIPE was asked %d times after its pause, want 2", ripeAsks)
}
}
// TestFetchAndUpdateBacksOffDuringAPause looks up an AS at RIPE while RIPE
// refuses the query. The refusal pauses RIPE for an hour, so every later
// lookup in the test is skipped. The test sets the WHOIS client's clock and
// looks the AS up again each time the interval has passed. Like a failure,
// each skip must make the interval 1.5 times as long, up to one minute. The
// skips must not count as failures.
func TestFetchAndUpdateBacksOffDuringAPause(t *testing.T) {
t.Parallel()
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{
ianaWHOISServer: ianaRefersToRIPE,
ripeWHOISServer: ripeAccessDenied,
})
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
for i, want := range []time.Duration{
33750 * time.Millisecond,
50625 * time.Millisecond,
time.Minute,
time.Minute,
} {
now = now.Add(f.getInterval())
f.fetchAndUpdate(t.Context(), testASNA)
if got := f.getInterval(); got != want {
t.Errorf("interval after %d skipped lookups = %v, want %v",
i+1, got, want)
}
}
got := f.GetStats()
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 {
t.Errorf("%d failures in a row and %d errors, want 1 and 1",
got.ConsecutiveFails, got.ErrorsLastHour)
}
}
// TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server
// refuses the connection. The test sets the WHOIS client's clock. The next
// lookup comes a second before whois.PauseTime has passed. IANA is never
// paused, so that lookup asks IANA again. Each refusal counts as a failure.
func TestFetchAndUpdateKeepsAskingIANA(t *testing.T) {
t.Parallel()
// With no answers, every server refuses the connection.
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{})
ianaAsks := 0
dial := f.whoisClient.DialContext
f.whoisClient.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ianaWHOISServer {
ianaAsks++
}
return dial(ctx, network, address)
}
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
now = start.Add(whois.PauseTime - time.Second)
f.fetchAndUpdate(t.Context(), testASNA)
got := f.GetStats()
if ianaAsks != 2 || got.ConsecutiveFails != 2 {
t.Errorf("IANA was asked %d times, with %d failures in a row; "+
"want 2 and 2", ianaAsks, got.ConsecutiveFails)
}
}
// TestRecordStepsTheInterval has the fetcher fail from its base interval of
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as
// long, up to one minute, and each success halves it, down to one second.
+36 -53
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"slices"
"sync"
@@ -178,14 +177,12 @@ type stopLog struct {
Msg string `json:"msg"`
Updates int `json:"updates"`
TableChanges int `json:"table_changes"`
HeldMessages int `json:"held_messages"`
Paths int `json:"paths"`
BatchEntries int `json:"batch_entries"`
}
// newErrorLogger returns a logger that writes only errors, as JSON lines, to
// logs.
func newErrorLogger(logs io.Writer) *logger.Logger {
func newErrorLogger(logs *bytes.Buffer) *logger.Logger {
return &logger.Logger{Logger: slog.New(slog.NewJSONHandler(logs,
&slog.HandlerOptions{Level: slog.LevelError}))}
}
@@ -498,20 +495,20 @@ func TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress(t *testing.T)
}
}
// TestASHandlerSkipsItsLastWriteAfterTheStopDeadline also checks what Stop
// logs: the asnBatchSize entries of the held write and the entry for testASNB,
// handled since, are not written.
func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
const wantEntries = asnBatchSize + 1
// Each message gives an update for its origin AS and one for each AS
// in its path: fill asnBatchSize+1, extra 2.
const wantUpdates = asnBatchSize + 1 + 2
var logs bytes.Buffer
store := newBlockingStore()
h := newASHandler(store, newErrorLogger(&logs), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}},
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
@@ -521,27 +518,26 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Errorf("AS%d was written after the stop deadline", testASNB)
}
if logged := lastLogged(t, &logs); logged.BatchEntries != wantEntries {
t.Errorf("Stop logged %d batch entries not written, want %d",
logged.BatchEntries, wantEntries)
if logged := lastLogged(t, &logs); logged.Updates != wantUpdates {
t.Errorf("Stop logged %d updates not written, want %d",
logged.Updates, wantUpdates)
}
}
// TestASHandlerSkipsAFullBatchAfterTheStopDeadline hands the AS handler a full
// batch while a write is held, so the batch's wake-up of the flush goroutine
// waits. The full batch holds the fill's ASes again, and testASNB. The stop
// deadline passes meanwhile. When the write ends, the flush goroutine must stop
// instead of writing the batch.
// waits. The stop deadline passes meanwhile. When the write ends, the flush
// goroutine must stop instead of writing the batch.
func TestASHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := &ristypes.RISMessage{Path: pathOfASes(asnBatchSize)}
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
full := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNB}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{fill},
fill,
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: full})
store.mu.Lock()
defer store.mu.Unlock()
@@ -707,8 +703,9 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}},
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
@@ -765,41 +762,25 @@ func TestPrefixHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
}
}
// TestASHandlerNeverWaitsForAWrite starts a write that the store holds. Then it
// hands the AS handler twenty messages whose paths each hold one AS
// asnBatchSize times. HandleMessage must return each time, and the batch must
// hold one entry, for that AS.
func TestASHandlerNeverWaitsForAWrite(t *testing.T) {
func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
const messages = 20
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
h.batchMaxSize = asnBatchSize
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
toMax := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
handleAtMaxBatchSize(t, store, h,
[]*ristypes.RISMessage{{Path: fill}},
[]*ristypes.RISMessage{{Path: toMax}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
defer stopInTime(t, h)
defer store.releaseWrites()
store.mu.Lock()
defer store.mu.Unlock()
startBlockedWrite(t, store, h,
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}})
oneAS := &ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: slices.Repeat([]int{testASNB}, asnBatchSize)},
}
if !returnsInTime(func() {
for range messages {
h.HandleMessage(oneAS)
}
}) {
t.Fatal("HandleMessage did not return while a database write was blocked")
}
h.mu.Lock()
defer h.mu.Unlock()
if len(h.batch) != 1 {
t.Errorf("the batch holds %d entries for one AS, want 1", len(h.batch))
if !store.asns[testASNB] {
t.Errorf("Stop did not write AS%d, handled after the batch reached its "+
"maximum size", testASNB)
}
}
@@ -863,11 +844,13 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
batchSize: asnBatchSize,
handle: func(n int) chan struct{} {
h := &ASHandler{
batch: make(map[int]database.SeenTimes),
batchFullCh: make(chan struct{}, 1),
batchMaxSize: asnBatchMaxSize,
batchFullCh: make(chan struct{}, 1),
}
// Each of the path's n ASes is one entry of the batch.
h.HandleMessage(&ristypes.RISMessage{Path: pathOfASes(n)})
// The origin AS and each AS in the path are one update each.
h.HandleMessage(&ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, n-1)},
})
return h.batchFullCh
},
+35 -163
View File
@@ -44,12 +44,6 @@ const (
// most one pair. So the million pairs that a full-table peer's session
// going down changes are written in about a minute.
maxTableChangesPerWrite = 20000
// maxHeldBackMessages is the most messages the handler holds back from
// peers whose PeerDown has not returned. A message takes about 1 KiB, so
// that many take about 20 MiB (README "Memory"). At this number
// HandleMessage waits until one of them is applied.
maxHeldBackMessages = 20000
)
// PrefixHandler keeps the routing table in memory current from the feed. It
@@ -58,15 +52,6 @@ const (
// does an announcement whose AS path is empty or holds AS 0 anywhere. A peer
// whose BGP session goes down leaves every prefix.
//
// The table's PeerDown applies a session going down. It visits every prefix,
// which takes seconds on a full table. So the handler runs PeerDown on
// a separate goroutine, one peer at a time, and goes on taking messages
// meanwhile. PeerDown lets the table's lock go about half of the time, so the
// handler applies other peers' messages while PeerDown runs. The handler holds
// back the down peer's later messages until PeerDown returns. Then the handler
// applies them in order, one at a time, and takes new messages in between. It
// holds back at most maxHeldBackMessages messages.
//
// Each flush writes the table's changes to the database. It also stores each
// prefix the updates name with the time it was first seen.
type PrefixHandler struct {
@@ -82,7 +67,7 @@ type PrefixHandler struct {
// give a smaller one.
tableChangesPerWrite int
// Batching. mu guards batch, writing and writingTableChanges: the
// Batching. mu guards batch, writing and writingTableChanges only: the
// flush goroutine takes them under mu and writes them after releasing
// mu, so HandleMessage waits for a database write only while batch is at
// batchMaxSize.
@@ -102,21 +87,6 @@ type PrefixHandler struct {
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
done chan struct{} // closed when the flush goroutine returns
// Peers whose session went down. peerDownMu lets one PeerDown run at a
// time, so the handler's own updates wait for the table's lock behind
// at most one. heldBack, under mu, holds each such peer's later
// messages, in order, until its PeerDown has returned and they are
// applied. A peer with no such messages yet has an empty entry.
// heldCount, under mu, is how many messages heldBack holds.
// heldTaken is signalled each time a held-back message is taken out to
// be applied; its L is &mu. peerDowns counts the peerDown goroutines
// that have not returned.
peerDownMu sync.Mutex
heldBack map[string][]*ristypes.RISMessage
heldCount int
heldTaken sync.Cond
peerDowns sync.WaitGroup
}
// seenPrefix is what the batch keeps of an update: the prefix it names and its
@@ -180,10 +150,8 @@ func newPrefixHandler(
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
heldBack: make(map[string][]*ristypes.RISMessage),
}
h.batchTaken.L = &h.mu
h.heldTaken.L = &h.mu
// Start the flush timer goroutine
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
@@ -211,91 +179,11 @@ func (h *PrefixHandler) QueueCapacity() int {
return prefixHandlerQueueSize
}
// HandleMessage processes a message to track prefix information. A message
// from a peer whose PeerDown has not returned is held back until it has.
// HandleMessage processes a message to track prefix information
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
h.mu.Lock()
defer h.mu.Unlock()
// While maxHeldBackMessages messages are held back, wait until one of them
// is taken out to be applied. Meanwhile the handler takes nothing off its
// queue, as at batchMaxSize.
for h.heldCount >= maxHeldBackMessages {
h.heldTaken.Wait()
}
held, goingDown := h.heldBack[msg.Peer]
if goingDown {
h.heldBack[msg.Peer] = append(held, msg)
h.heldCount++
return
}
h.applyMessage(msg)
}
// Stop waits for every PeerDown the handler started and for the messages held
// back meanwhile to be applied. It gives up this wait once ctx ends. Then Stop
// stops the flush goroutine and waits for it until ctx ends. The flush
// goroutine makes one last flush before it returns. That flush writes at most
// tableChangesPerWrite of the routing table's changes, and the prefixes of the
// updates the handler holds. Once ctx has ended the flush goroutine skips that
// last flush, and a write still running is lost when the process exits. A
// failed write of the table's changes is not retried either. Stop logs how
// many updates and table changes are not written. It also logs how many
// held-back messages are not applied. The caller calls Stop once its last
// HandleMessage has returned.
func (h *PrefixHandler) Stop(ctx context.Context) {
peerDownsDone := make(chan struct{})
go func() {
h.peerDowns.Wait()
close(peerDownsDone)
}()
select {
case <-peerDownsDone:
case <-ctx.Done():
}
h.stopCh <- ctx
select {
case <-h.done:
case <-ctx.Done():
}
h.mu.Lock()
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
heldMessages := h.heldCount
h.mu.Unlock()
if unwritten == 0 && tableChanges == 0 && heldMessages == 0 {
return
}
if ctx.Err() != nil {
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
"its updates", "updates", unwritten, "table_changes", tableChanges,
"held_messages", heldMessages)
return
}
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "table_changes", tableChanges,
"held_messages", heldMessages)
}
// applyMessage applies msg. The caller holds mu. A peer's session going down
// starts the peer's PeerDown. An update is applied to the routing table, and
// its prefixes join the batch.
func (h *PrefixHandler) applyMessage(msg *ristypes.RISMessage) {
if msg.Type == ristypes.MessageTypeState {
if msg.State == ristypes.StateDown {
h.startPeerDown(msg.Peer)
h.table.PeerDown(msg.Peer)
}
return
@@ -310,6 +198,9 @@ func (h *PrefixHandler) applyMessage(msg *ristypes.RISMessage) {
// the path after the rest of it.
path := slices.Concat(msg.Path.Sequence, msg.Path.Set)
h.mu.Lock()
defer h.mu.Unlock()
// While the batch is at its maximum size, wait until the flush goroutine
// takes it. Meanwhile the handler takes nothing off its queue, which
// fills, and the streamer drops messages for it.
@@ -370,58 +261,39 @@ func (h *PrefixHandler) applyMessage(msg *ristypes.RISMessage) {
}
}
// startPeerDown starts peerDown for peer on a new goroutine. The caller holds
// mu. The peer's messages are held back until peerDown has applied them.
// Messages held back for an earlier PeerDown of the peer, and not yet applied,
// stay held back.
func (h *PrefixHandler) startPeerDown(peer string) {
_, goingDown := h.heldBack[peer]
if !goingDown {
h.heldBack[peer] = []*ristypes.RISMessage{}
// Stop stops the flush goroutine, which makes one last flush before it returns,
// and waits for it until ctx ends. That flush writes at most
// tableChangesPerWrite of the routing table's changes, and the prefixes of
// the updates the handler holds. Once ctx has ended the flush goroutine skips
// that last flush, and a write still running is lost when the process exits. A
// failed write of the table's changes is not retried either. Stop logs how many
// updates and how many of the table's changes are not written.
func (h *PrefixHandler) Stop(ctx context.Context) {
h.stopCh <- ctx
select {
case <-h.done:
case <-ctx.Done():
}
h.peerDowns.Add(1)
h.mu.Lock()
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
h.mu.Unlock()
go h.peerDown(peer)
}
// peerDown runs the table's PeerDown for peer, once no other PeerDown is
// running. Then peerDown applies the messages held back from the peer, in
// order. It takes mu for one message at a time, so HandleMessage takes new
// messages in between. The peer's new messages join the end of the held-back
// ones. A held-back message that says the peer's session went down again
// starts a new peerDown, which applies the rest.
func (h *PrefixHandler) peerDown(peer string) {
defer h.peerDowns.Done()
h.peerDownMu.Lock()
h.table.PeerDown(peer)
h.peerDownMu.Unlock()
for {
h.mu.Lock()
held := h.heldBack[peer]
if len(held) == 0 {
delete(h.heldBack, peer)
h.mu.Unlock()
return
}
msg := held[0]
held[0] = nil // so that the message is freed once applied
h.heldBack[peer] = held[1:]
h.heldCount--
h.heldTaken.Broadcast()
h.applyMessage(msg)
h.mu.Unlock()
if msg.Type == ristypes.MessageTypeState &&
msg.State == ristypes.StateDown {
return
}
if unwritten == 0 && tableChanges == 0 {
return
}
if ctx.Err() != nil {
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
"its updates", "updates", unwritten, "table_changes", tableChanges)
return
}
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "table_changes", tableChanges)
}
// takeBatch returns the pending updates and leaves an empty batch in their
@@ -237,83 +237,6 @@ func TestPrefixHandlerStopLogsNothingWhenItWroteEverything(t *testing.T) {
}
}
// TestPrefixHandlerStopLogsTheMessagesHeldBack stops a prefix handler with a
// deadline that has passed, while a peer's PeerDown is held up and one message
// of the peer is held back. Stop must log that message as not applied.
func TestPrefixHandlerStopLogsTheMessagesHeldBack(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
h := newPrefixHandler(newMockStore(), newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
h.peerDownMu.Lock()
defer h.peerDownMu.Unlock()
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.HandleMessage(sessionDown())
h.HandleMessage(peerAnnouncement(testPeerIP, testV6Prefix))
ctx, cancel := context.WithCancel(t.Context())
cancel()
h.Stop(ctx)
logged := lastLogged(t, &logs)
if logged.HeldMessages != 1 {
t.Errorf("Stop logged %d held messages not applied, want 1",
logged.HeldMessages)
}
}
// TestPrefixHandlerStopWaitsForTheMessagesHeldBack stops a prefix handler
// while a peer's PeerDown is held up and an announcement of the peer is held
// back. Stop must wait. Once the PeerDown is let go and Stop returns, the
// announcement must be in the database.
func TestPrefixHandlerStopWaitsForTheMessagesHeldBack(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
h := newPrefixHandler(db, logger.New(), routetable.New(routetable.Params{}),
longerThanAnyTest, maxTableChangesPerWrite)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
stopped := make(chan struct{})
go func() {
h.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("Stop returned while a PeerDown was held up")
case <-time.After(stillWaitingAfter):
}
h.peerDownMu.Unlock()
select {
case <-stopped:
case <-time.After(stepTimeout):
t.Fatalf("Stop did not return within %s of the PeerDown's release",
stepTimeout)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkTableOrigins(t, read, map[string]map[int][]string{
testV4Prefix: {testASNB: {testPeerIP}},
})
}
// longerThanAnyTest is a timer interval that no test lasts. A test gives it to
// newPrefixHandler, newASHandler, newPeerHandler or newPeeringHandler when the
// handler must write only when its batch is full, when the test says, or on
@@ -330,7 +253,6 @@ func prefixHandlerWithoutTimer(store database.Store) *PrefixHandler {
table: routetable.New(routetable.Params{}),
batchMaxSize: prefixBatchMaxSize,
tableChangesPerWrite: maxTableChangesPerWrite,
heldBack: make(map[string][]*ristypes.RISMessage),
}
}
@@ -7,10 +7,8 @@ import (
"log/slog"
"net/netip"
"reflect"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -103,228 +101,6 @@ func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) {
})
}
// TestPrefixHandlerTakesMessagesWhileAPeerGoesDown holds up the PeerDown of a
// peer whose session went down, as another peer's long PeerDown would. The
// handler must go on taking messages. It applies another peer's announcement
// at once. It holds back these messages of the down peer, in this order:
// - an announcement;
// - its session going down again;
// - a second announcement.
//
// Once the PeerDowns have returned, the down peer must carry only the prefix
// of the second announcement.
func TestPrefixHandlerTakesMessagesWhileAPeerGoesDown(t *testing.T) {
t.Parallel()
h := prefixHandlerWithoutTimer(newMockStore())
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.peerDownMu.Lock()
handled := returnsInTime(func() {
for _, msg := range []*ristypes.RISMessage{
sessionDown(),
peerAnnouncement(secondPeerIP, otherV4Prefix),
peerAnnouncement(testPeerIP, testV6Prefix),
sessionDown(),
peerAnnouncement(testPeerIP, setV4Prefix),
} {
h.HandleMessage(msg)
}
})
if !handled {
t.Fatalf("the handler did not take the messages after the session "+
"went down within %s", stepTimeout)
}
checkTableOrigins(t, h.table, map[string]map[int][]string{
testV4Prefix: {testASNB: {testPeerIP}},
otherV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
setV4Prefix: {},
})
h.peerDownMu.Unlock()
if !returnsInTime(h.peerDowns.Wait) {
t.Fatalf("the PeerDowns did not return within %s", stepTimeout)
}
checkTableOrigins(t, h.table, map[string]map[int][]string{
testV4Prefix: {},
otherV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
setV4Prefix: {testASNB: {testPeerIP}},
})
}
// TestPrefixHandlerAppliesMessagesWhilePeerDownRuns gives a peer 50,000
// prefixes and hands the prefix handler that peer's session going down. The
// table's PeerDown then visits the prefixes in five batches. Once PeerDown has
// begun, the handler is handed 1,000 announcements of another peer. The
// handler must apply them all before PeerDown ends, while the down peer still
// carries some of its prefixes.
func TestPrefixHandlerAppliesMessagesWhilePeerDownRuns(t *testing.T) {
t.Parallel()
const (
// downPeerPrefixes is five times the 10,000 prefixes PeerDown visits
// in one hold of the table's lock.
downPeerPrefixes = 50000
// otherPeerMessages is far more messages than the handler applies
// while PeerDown runs, if PeerDown takes the lock back at once after
// each batch. The handler then applies only a few for each batch.
otherPeerMessages = 1000
// firstOtherAddr is the address of the other peer's first prefix.
firstOtherAddr = "2001:db8:2::"
)
h := prefixHandlerWithoutTimer(newMockStore())
announceManyPrefixes(h.table, downPeerPrefixes)
h.HandleMessage(sessionDown())
// Wait until PeerDown has begun: the down peer has left some of its
// prefixes. The down peer's prefixes are IPv4 and the other peer's are
// IPv6, so the counts tell them apart.
for h.table.Counts().IPv4Routes == downPeerPrefixes {
runtime.Gosched()
}
// Each announcement names the /128 of the next address.
addr := netip.MustParseAddr(firstOtherAddr)
for range otherPeerMessages {
h.HandleMessage(peerAnnouncement(secondPeerIP,
netip.PrefixFrom(addr, addr.BitLen()).String()))
addr = addr.Next()
}
counts := h.table.Counts()
if counts.IPv6Routes != otherPeerMessages {
t.Errorf("the other peer carries %d prefixes, want %d",
counts.IPv6Routes, otherPeerMessages)
}
if counts.IPv4Routes == 0 {
t.Errorf("PeerDown ended before the handler applied the other peer's "+
"%d announcements", otherPeerMessages)
}
if !returnsInTime(h.peerDowns.Wait) {
t.Fatalf("the PeerDown did not return within %s", stepTimeout)
}
}
// TestPrefixHandlerTakesMessagesWhileItAppliesHeldBackOnes holds back many
// messages of a peer whose session went down. Each names a prefix that does
// not parse, so applying it logs an error. Each log write takes a
// millisecond. While the handler applies the held-back messages, it must take
// another peer's announcement.
func TestPrefixHandlerTakesMessagesWhileItAppliesHeldBackOnes(t *testing.T) {
t.Parallel()
const (
// heldMessages take the handler at least a second to apply.
heldMessages = 1000
badPrefix = "not a prefix"
)
logs := &slowWriter{firstWrite: make(chan struct{})}
h := newPrefixHandler(newMockStore(), newErrorLogger(logs),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
defer stopInTime(t, h)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
for range heldMessages {
h.HandleMessage(peerAnnouncement(testPeerIP, badPrefix))
}
h.peerDownMu.Unlock()
select {
case <-logs.firstWrite:
case <-time.After(stepTimeout):
t.Fatal("the handler did not start to apply the held-back messages")
}
if !returnsInTime(func() {
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
}) {
t.Fatalf("HandleMessage did not return within %s", stepTimeout)
}
h.mu.Lock()
left := len(h.heldBack[testPeerIP])
h.mu.Unlock()
if left == 0 {
t.Error("the handler took another peer's announcement only once it " +
"had applied every held-back message")
}
}
// TestPrefixHandlerWaitsAtTheMaximumHeldBack holds up the PeerDown of a peer
// whose session went down. It hands the handler maxHeldBackMessages messages
// of that peer. HandleMessage must then wait, even for another peer's
// message, until the PeerDown returns and a held-back message is applied.
func TestPrefixHandlerWaitsAtTheMaximumHeldBack(t *testing.T) {
t.Parallel()
h := newPrefixHandler(newMockStore(), logger.New(),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
defer stopInTime(t, h)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
held := peerAnnouncement(testPeerIP, testV6Prefix)
for range maxHeldBackMessages {
h.HandleMessage(held)
}
returned := make(chan struct{})
go func() {
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
close(returned)
}()
select {
case <-returned:
t.Error("HandleMessage returned while maxHeldBackMessages messages " +
"were held back")
case <-time.After(stillWaitingAfter):
}
h.peerDownMu.Unlock()
select {
case <-returned:
case <-time.After(stepTimeout):
t.Fatal("HandleMessage did not return after the PeerDown returned")
}
}
// slowWriter takes a millisecond over each write and drops what it is given.
// It closes firstWrite at the first write.
type slowWriter struct {
firstWrite chan struct{}
once sync.Once
}
func (w *slowWriter) Write(p []byte) (int, error) {
w.once.Do(func() { close(w.firstWrite) })
time.Sleep(time.Millisecond)
return len(p), nil
}
// TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable hands the
// prefix handler announcements whose AS path gives no origin. One whose path
// is empty or holds AS 0 removes the peer's route, as a withdrawal does; one
@@ -423,9 +199,8 @@ func TestPrefixHandlerKeepsThePathOfAPathEndingInASet(t *testing.T) {
}
}
// checkTableCases hands the messages of each case to a new prefix handler. It
// checks the handler's routing table once the handler's PeerDowns have
// returned, with no flush.
// checkTableCases hands the messages of each case to a new prefix handler and
// checks its routing table right after, with no flush.
func checkTableCases(t *testing.T, tests []tableCase) {
t.Helper()
@@ -438,28 +213,17 @@ func checkTableCases(t *testing.T, tests []tableCase) {
h.HandleMessage(msg)
}
h.peerDowns.Wait()
checkTableOrigins(t, h.table, tt.want)
for prefix, want := range tt.want {
got := tableOrigins(h.table, prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("peers of %s by origin AS = %v, want %v",
prefix, got, want)
}
}
})
}
}
// checkTableOrigins checks, for each prefix in want, the peers that carry it
// in table with each origin AS.
func checkTableOrigins(
t *testing.T, table *routetable.Table, want map[string]map[int][]string,
) {
t.Helper()
for prefix, wantOrigins := range want {
got := tableOrigins(table, prefix)
if !reflect.DeepEqual(got, wantOrigins) {
t.Errorf("peers of %s by origin AS = %v, want %v",
prefix, got, wantOrigins)
}
}
}
// tableWritesStore is a real database that hands each write of the routing
// table's changes to written once it is made.
type tableWritesStore struct {
@@ -734,7 +498,6 @@ func TestARestartReadsTheRoutingTableBack(t *testing.T) {
h.HandleMessage(msg)
}
h.peerDowns.Wait()
h.flushBatch()
var logs bytes.Buffer
@@ -773,7 +536,6 @@ func TestARestartReadsTheRoutingTableBack(t *testing.T) {
restarted.HandleMessage(msg)
}
h.peerDowns.Wait()
restarted.Stop(t.Context())
read, err := db.ReadRouteTable(t.Context())
+36 -82
View File
@@ -12,7 +12,6 @@ import (
"regexp"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
@@ -71,12 +70,12 @@ const nicbrHeader = "% Copyright (c) Nic.br"
// queries to stop the data being taken in bulk.
const nicbrLimitReached = "Query rate limit exceeded"
// PauseTime is how long the client sends an RIR's or NIC.br's server no
// query after that server refuses a query. queryRegistry says what a
// refusal is. RIPE refuses an address for good when the address keeps
// passing RIPE's limit (see noPersonalFlag). An hour outlasts a limit
// counted per minute or per hour.
const PauseTime = time.Hour
// NICbrSkipTime is how long the client stops asking NIC.br after NIC.br
// answers that the client is over its query limit. NIC.br publishes
// neither the limit nor how long it lasts. An hour outlasts a limit
// counted per minute or per hour. The ASes looked up meanwhile get
// NIC.br's fields when they are next refreshed.
const NICbrSkipTime = time.Hour
// RIR identifiers.
const (
@@ -126,10 +125,6 @@ var (
errAnswerTooLarge = errors.New("WHOIS answer reached the size cap")
)
// ErrPaused is returned when a lookup needs a server that refused a query
// less than PauseTime ago. The client sends that server nothing meanwhile.
var ErrPaused = errors.New("WHOIS server paused after refusing a query")
// ASNInfo contains parsed WHOIS information for an ASN.
type ASNInfo struct {
ASN int
@@ -161,12 +156,12 @@ type Client struct {
logger *slog.Logger
// mu guards pausedUntil.
// mu guards nicbrSkipUntil.
mu sync.Mutex
// pausedUntil maps a server, given as host:port, to the time the client
// may ask it again.
pausedUntil map[string]time.Time
// nicbrSkipUntil is when the client next asks NIC.br, after NIC.br
// answered that the client is over its query limit.
nicbrSkipUntil time.Time
}
// NewClient creates a new WHOIS client that logs to logger.
@@ -177,29 +172,23 @@ func NewClient(logger *slog.Logger) *Client {
DialContext: dialer.DialContext,
Now: time.Now,
logger: logger,
pausedUntil: make(map[string]time.Time),
}
}
// LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and
// returns what the RIR's answer says. When LACNIC's answer is NIC.br's, it
// returns what NIC.br's own server says instead, unless that query fails or
// NIC.br is paused, as askNICbr says.
// is skipped, as askNICbr says.
//
// It returns ErrNoReferral when IANA refers the AS to no RIR. Its error
// wraps ErrPaused when the RIR is paused. It returns another error in each
// of these cases:
//
// - the query to IANA or to the RIR fails
// - the RIR refuses the query
// - IANA's answer has neither a referral nor the AS's block
// - IANA refers the AS to a server none of the five RIRs run
// - the answer carries none of ASNInfo's fields
// It returns ErrNoReferral when IANA refers the AS to no RIR, and another
// error when the query to IANA or to the RIR fails, when the RIR refuses
// it, when IANA's answer has neither a referral nor the AS's block, when
// IANA refers the AS to a server none of the five RIRs run, or when the
// answer carries none of ASNInfo's fields.
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn)
// IANA is never paused, because a pause of IANA would stop every lookup.
ianaResp, err := c.query(ctx, whoisServerIANA, query)
if err != nil {
return nil, fmt.Errorf("IANA query failed: %w", err)
@@ -222,7 +211,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
}
// Query the authoritative RIR
rirResp, err := c.queryRegistry(ctx, whoisServer, rirQuery(rir, query))
rirResp, err := c.queryRIR(ctx, whoisServer, rirQuery(rir, query))
if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
@@ -252,19 +241,22 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// askNICbr returns lacnicInfo, what LACNIC's answer says, and logs a
// warning when the query fails, when NIC.br sends the reduced answer for a
// client over its limit, or when NIC.br's answer carries none of ASNInfo's
// fields. The reduced answer pauses NIC.br, as a refusal does. While NIC.br
// is paused, askNICbr returns lacnicInfo without a warning. The ASes looked
// up meanwhile get NIC.br's fields when they are next refreshed.
// fields. After the reduced answer, it returns lacnicInfo without asking
// NIC.br until NICbrSkipTime has passed.
func (c *Client) askNICbr(
ctx context.Context, asQuery string, lacnicInfo *ASNInfo,
) *ASNInfo {
asn := lacnicInfo.ASN
answer, err := c.queryRegistry(ctx, whoisServerNICbr, asQuery)
if errors.Is(err, ErrPaused) {
c.mu.Lock()
skipping := c.Now().Before(c.nicbrSkipUntil)
c.mu.Unlock()
if skipping {
return lacnicInfo
}
answer, err := c.query(ctx, whoisServerNICbr, asQuery)
if err != nil {
c.logger.Warn("NIC.br query failed, keeping LACNIC's fields",
"asn", asn, "error", err)
@@ -273,11 +265,13 @@ func (c *Client) askNICbr(
}
if strings.Contains(answer, nicbrLimitReached) {
c.pause(whoisServerNICbr)
c.mu.Lock()
c.nicbrSkipUntil = c.Now().Add(NICbrSkipTime)
c.mu.Unlock()
c.logger.Warn("NIC.br answered that the client is over its query "+
"limit, keeping LACNIC's fields and pausing NIC.br",
"asn", asn, "pause_for", PauseTime)
"limit, keeping LACNIC's fields and skipping NIC.br",
"asn", asn, "skip_for", NICbrSkipTime)
return lacnicInfo
}
@@ -315,65 +309,25 @@ func rirQuery(rir, asQuery string) string {
}
}
// queryRegistry sends query to the server of an RIR or of NIC.br, and
// returns the answer. The client pauses the server for PauseTime when the
// server refuses the query. Each of these is a refusal:
//
// - a refused connection
// - a reset connection
// - an %ERROR:201: or %ERROR:202: line in the answer
//
// A refusal is returned as an error. While the server is paused,
// queryRegistry sends it nothing and returns an error that wraps ErrPaused.
func (c *Client) queryRegistry(
// queryRIR sends query to an RIR's server and returns the answer. An answer
// holding an error line with which the server refuses the query is an
// error, and the error holds that line.
func (c *Client) queryRIR(
ctx context.Context, server, query string,
) (string, error) {
c.mu.Lock()
pausedUntil := c.pausedUntil[server]
c.mu.Unlock()
if c.Now().Before(pausedUntil) {
return "", fmt.Errorf("%w: %s until %s", ErrPaused, server,
pausedUntil.UTC().Format(time.RFC3339))
}
answer, err := c.query(ctx, server, query)
if err == nil {
err = checkRefusal(answer)
}
if errors.Is(err, errRefused) || errors.Is(err, syscall.ECONNREFUSED) ||
errors.Is(err, syscall.ECONNRESET) {
c.pause(server)
}
if err != nil {
return "", err
}
return answer, nil
}
// checkRefusal returns an error holding the first line of answer that
// starts with accessDeniedError or accessLimitError. It returns nil when
// answer has no such line.
func checkRefusal(answer string) error {
for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) ||
strings.HasPrefix(line, accessLimitError) {
return fmt.Errorf("%w: %s", errRefused, line)
return "", fmt.Errorf("%w: %s", errRefused, line)
}
}
return nil
}
// pause stops queryRegistry from querying server until PauseTime has passed.
func (c *Client) pause(server string) {
c.mu.Lock()
defer c.mu.Unlock()
c.pausedUntil[server] = c.Now().Add(PauseTime)
return answer, nil
}
// query performs a raw WHOIS query to the specified server and returns the
+5 -142
View File
@@ -718,10 +718,9 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
// TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br
// registers three times with one client, while NIC.br answers that the
// client is over its query limit. The test sets the client's clock. The
// first lookup asks NIC.br. The next comes a second before PauseTime has
// passed, and does not ask NIC.br. The last comes at PauseTime and asks
// NIC.br again. Each keeps LACNIC's fields.
// client is over its query limit. The first lookup asks NIC.br. The
// second, a second before NICbrSkipTime has passed, does not. The third,
// once NICbrSkipTime has passed, asks again. Each keeps LACNIC's fields.
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
t.Parallel()
@@ -741,8 +740,8 @@ func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
wantAsks int
}{
{after: 0, wantAsks: 1},
{after: whois.PauseTime - time.Second, wantAsks: 1},
{after: whois.PauseTime, wantAsks: 2},
{after: whois.NICbrSkipTime - time.Second, wantAsks: 1},
{after: whois.NICbrSkipTime, wantAsks: 2},
} {
now = start.Add(step.after)
@@ -786,142 +785,6 @@ func TestLookupASNReportsARefusal(t *testing.T) {
}
}
// resetServer starts a TCP server on the loopback address and returns the
// server's address. The server reads one query on each connection, then
// resets the connection.
func resetServer(t *testing.T) string {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_, _ = bufio.NewReader(conn).ReadString('\n')
// Closing a connection whose linger time is zero resets it.
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetLinger(0)
}
_ = conn.Close()
}
}()
return listener.Addr().String()
}
// newRIPEClient returns a client whose fake servers send answers. The
// client adds one to *ripeAsks each time it connects to RIPE's server. When
// reset is true, RIPE's server resets the connection instead of answering.
func newRIPEClient(
t *testing.T, answers map[string]string, reset bool, ripeAsks *int,
) *whois.Client {
t.Helper()
dial := fakeServers(t, answers)
resetAddress := resetServer(t)
client := whois.NewClient(slog.New(slog.DiscardHandler))
client.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ripeServer {
*ripeAsks++
}
if address == ripeServer && reset {
var dialer net.Dialer
return dialer.DialContext(ctx, network, resetAddress)
}
return dial(ctx, network, address)
}
return client
}
// TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE
// refuses the query, in each of the ways a server refuses. The test sets
// the client's clock. A lookup a second before PauseTime has passed fails
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC succeeds
// during the pause. A lookup at PauseTime asks RIPE again.
func TestLookupASNPausesAServerThatRefuses(t *testing.T) {
t.Parallel()
for name, test := range map[string]struct {
// ripeAnswer is RIPE's answer. With none, RIPE's server refuses
// the connection, or resets it when reset is true.
ripeAnswer string
reset bool
}{
"access denied": {ripeAnswer: ripeRefusal},
"limit reached": {ripeAnswer: ripeLimitReached + "\n"},
"refused connection": {},
"reset connection": {reset: true},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
answers := map[string]string{
ianaQuery: ianaReferral,
apnicIANAQuery: "refer: whois.apnic.net\n",
apnicQuery: apnicRecord,
}
if test.ripeAnswer != "" {
answers[ripeQuery] = test.ripeAnswer
}
ripeAsks := 0
client := newRIPEClient(t, answers, test.reset, &ripeAsks)
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
client.Now = func() time.Time { return now }
_, err := client.LookupASN(t.Context(), testASN)
if err == nil || errors.Is(err, whois.ErrPaused) {
t.Fatalf("LookupASN while RIPE refuses: error %v, want a "+
"refusal", err)
}
now = start.Add(whois.PauseTime - time.Second)
_, err = client.LookupASN(t.Context(), testASN)
if !errors.Is(err, whois.ErrPaused) || ripeAsks != 1 {
t.Errorf("LookupASN during the pause: error %v after %d "+
"queries to RIPE, want ErrPaused after 1", err, ripeAsks)
}
_, err = client.LookupASN(t.Context(), apnicASN)
if err != nil {
t.Errorf("LookupASN of an AS at APNIC during RIPE's "+
"pause: %v", err)
}
now = start.Add(whois.PauseTime)
_, _ = client.LookupASN(t.Context(), testASN)
if ripeAsks != 2 {
t.Errorf("RIPE was asked %d times after the pause, want 2",
ripeAsks)
}
})
}
}
// TestLookupASNFailsWhenTheRIRGivesNoAnswer looks up an AS IANA refers to
// RIPE when RIPE's server refuses the connection, and when it answers only
// with an error. Neither is a lookup that found the AS.