diff --git a/Dockerfile b/Dockerfile index 5351025..e54904d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,10 +121,11 @@ RUN chown -R routewatch:routewatch /app ENV XDG_DATA_HOME=/var/lib -# Cap the Go heap at 1.5 GiB so the runtime collects harder before the -# container's memory limit is reached. setpriv in the entrypoint preserves this -# the way it does XDG_DATA_HOME above. -ENV GOMEMLIMIT=1536MiB +# Cap the Go heap at 2.5 GiB so the runtime collects harder before the +# container's memory limit is reached. A full routing table takes about 1 GB +# of it (README "Memory"). setpriv in the entrypoint preserves this the way it +# does XDG_DATA_HOME above. +ENV GOMEMLIMIT=2560MiB # Cap glibc's malloc arenas. The SQLite C library allocates and frees millions # of small page-cache chunks from many threads; glibc otherwise creates up to diff --git a/README.md b/README.md index 3eb652b..29fcb56 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing table in real time. It works like this: - It streams BGP UPDATE messages from the RIPE RIS Live service. -- It keeps a live routing table in SQLite. +- It keeps the whole routing table in memory, with a copy in SQLite. - It serves web pages and a JSON API for looking routes up. ## Rationale @@ -268,31 +268,36 @@ routewatch/ ### Component Relationships +`internal/routewatch/app.go` builds and starts these parts: + ``` -┌─────────────────────────────────────────────────────────────────┐ -│ RouteWatch │ -│ (internal/routewatch/app.go - main orchestrator) │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Streamer │───▶│ Handlers │───▶│ Database │ │ -│ │ │ │ │ │ │ │ -│ │ RIS Live │ │ - ASHandler │ │ SQLite with │ │ -│ │ HTTP stream │ │ - PeerHandler│ │ WAL mode │ │ -│ │ client │ │ - PrefixHdlr │ │ │ │ -│ │ │ │ - PeeringHdlr│ │ Tables: │ │ -│ └──────────────┘ └──────────────┘ │ - asns │ │ -│ │ - prefixes │ │ -│ ┌──────────────┐ ┌──────────────┐ │ - live_routes│ │ -│ │ Server │───▶│ Handlers │───▶│ - peerings │ │ -│ │ │ │ │ │ - bgp_peers │ │ -│ │ Chi router │ │ Status, API │ └──────────────┘ │ -│ │ port 8080 │ │ AS, Prefix │ │ -│ └──────────────┘ └──────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Streamer │───▶│ Handlers │───▶│ Database │ +│ │ │ │ │ │ +│ RIS Live │ │ - ASHandler │ │ SQLite with │ +│ HTTP stream │ │ - PeerHandler│ │ WAL mode │ +│ client │ │ - PrefixHdlr │ │ │ +│ │ │ - PeeringHdlr│ │ Tables: │ +└──────────────┘ └──────┬───────┘ │ - asns │ + │ │ - prefixes │ + ▼ │ - routes │ + ┌──────────────┐ │ - route_peers│ + │ Routing table│───▶│ - peerings │ + │ in memory │ │ - bgp_peers │ + └──────────────┘ └──────────────┘ + ▲ ▲ +┌──────────────┐ ┌──────┴───────┐ │ +│ Server │───▶│ Handlers │───────────┘ +│ │ │ │ +│ Chi router │ │ Status, API │ +│ port 8080 │ │ AS, Prefix │ +└──────────────┘ └──────────────┘ ``` +The prefix handler keeps the routing table in memory current. It writes the +table's changes to the database every second. The web handlers read routes from +the routing table, and everything else from the database. + ### Execution Flow 1. **Startup** (`cmd/routewatch/main.go` → `internal/routewatch/cli.go`) @@ -301,15 +306,17 @@ routewatch/ 2. **Initialization** (`internal/routewatch/app.go`) - Database created with SQLite schema (WAL mode, page caches as in "Memory") - - The routing table read from the database into memory, before the streamer - connects + - The routing table read from the `routes` table into memory, before the + streamer connects. A full table takes about 25 seconds to read. - Message handlers registered with the streamer - HTTP server started on configured port 3. **Message Processing Pipeline** ``` - RIS Live Stream → JSON Parser → Message Dispatcher → Handler Queues → Batch Writers → SQLite + RIS Live Stream → JSON Parser → Message Dispatcher → Handler Queues + ├→ Batch Writers → SQLite + └→ PrefixHandler → Routing Table in Memory → SQLite, every second ``` - Streamer connects to `ris-live.ripe.net` via HTTP @@ -324,24 +331,41 @@ routewatch/ 4. **Handler Details** - **ASHandler**: Tracks all ASNs seen in AS paths - **PeerHandler**: Records BGP peer information - - **PrefixHandler**: Maintains live routing table (upserts on announcement, - deletes on withdrawal or on an announcement whose AS path is empty or - holds AS 0 anywhere, deletes all of a peer's routes when its BGP session - goes down). Routes are kept per peer IP, so when one peer IP has sessions - on two collectors, a session going down on one also removes the routes the - other still carries, until they are announced again. It also 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 prefix and - origin AS pairs, and the rest follow in the next writes. + - **PrefixHandler**: Keeps the routing table in memory current. It applies + each update to the table as it handles it: + - 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. + + 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 + in the next writes. It also stores each new prefix with the time it was + first seen. Peers are known by their IP address. A peer IP can have + sessions on two collectors. A session going down on one then also + removes the routes the other still carries, until they are announced + again. + - **PeeringHandler**: Extracts AS peering relationships from AS paths 5. **HTTP Request Flow** ``` - Request → Chi Router → Middleware (timeout, logging) → Handler → Database Query → Response + Request → Chi Router → Middleware (timeout, logging) → Handler → Routing Table or Database → Response ``` ### Key Design Patterns +- **Routing Table in Memory**: `internal/routetable` holds the whole routing + table. For each prefix and origin AS it keeps: + - every peer whose current route to the prefix has that origin AS, one bit + per peer; + - the newest two of those routes in full, with AS path and time. + + Of the other routes, only the peer is kept. The prefix handler writes the + changed pairs to the `routes` table every second, one row per prefix and + origin AS. Startup reads that table back into memory before the streamer + connects. A crash loses only the changes not yet written. + - **Batched Writes**: The handlers write the feed's updates to the database in batches for performance - **Backpressure**: Probabilistic message dropping when queues exceed 50% @@ -373,13 +397,14 @@ asns(asn PRIMARY KEY, handle, description, prefixes_v4(prefix PRIMARY KEY, first_seen) WITHOUT ROWID prefixes_v6(prefix PRIMARY KEY, first_seen) WITHOUT ROWID --- Live routing tables (one per IP version), one route per peer and prefix -live_routes_v4(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn, - peer_ip, as_path, next_hop, last_updated, - UNIQUE(peer_ip, prefix)) -live_routes_v6(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn, - peer_ip, as_path, next_hop, last_updated, - UNIQUE(peer_ip, prefix)) +-- The routing table, one row per prefix and origin AS that some peer +-- carries: the peer set, and the newest two routes in full +routes(prefix, origin_asn, peers, + newest_peer, newest_as_path, newest_time, + older_peer, older_as_path, older_time, + PRIMARY KEY (prefix, origin_asn)) WITHOUT ROWID +-- The peers of the routes table, each with its number in the peer sets +route_peers(number PRIMARY KEY, address UNIQUE) -- Relationship tracking peerings(id PRIMARY KEY, as_a, as_b, first_seen, last_seen, @@ -403,7 +428,7 @@ Configuration is handled via environment variables and OS-specific paths: | `PORT` | `8080` | HTTP server port, a whole number from 1 to 65535 | | `DEBUG` | (empty) | Set to `routewatch` for debug logging | | `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path | -| `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory | +| `GOMEMLIMIT` | `2560MiB` (in the Docker image) | Go soft memory limit; see Memory | | `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory | | `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below | @@ -429,28 +454,37 @@ State directory (the database and the blocklist copies): ## Memory -The daemon holds a live routing table, so its memory grows with the size of the -data it tracks. The image sets ceilings that keep it inside a 5 GiB container. +The daemon keeps the whole routing table in memory. The image sets ceilings that +keep it inside a 5 GiB container. Budget: -- Go heap: a 1.5 GiB soft limit (`GOMEMLIMIT=1536MiB`, set in the image). -- SQLite: 1 GiB of page cache for the one connection every write uses, which - keeps the pages the route writes touch from one batch to the next, and 16 MiB - for each of the 9 reading connections, whose cache SQLite empties whenever - another connection has written: about 1.15 GiB in all. A 1.5 GiB soft heap - limit sits above the caches, so it does not shrink them, and a 2 GiB hard heap - limit caps the C library. -- glibc allocator: the SQLite C library runs on glibc `malloc`, which frees - page-cache chunks back to per-arena free lists rather than to the kernel, so - process RSS tracks the high-water mark of those arenas, not SQLite's live - heap. glibc creates up to eight arenas per core, so on a many-core host the - retained memory — and thus RSS — grows with the core count. The image sets - `MALLOC_ARENA_MAX=2` to bound it; the two-arena cap costs nothing here because - database writes are already serialized. +- 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 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. + - The connection that does every write has 512 MiB. It keeps the pages the + writes touch from one write to the next. With a full routing table, the + `routes` table takes about 450 MB, so its pages fit. + - Each of the 9 reading connections has 16 MiB. SQLite empties a reading + connection's cache whenever another connection has written. + - A 768 MiB soft heap limit sits above the caches, so it does not shrink + them. A 1 GiB hard heap limit caps the C library. +- glibc allocator: the SQLite C library allocates memory with glibc `malloc`. + - glibc keeps freed page-cache chunks in per-arena free lists instead of + returning them to the kernel. So the process's RSS follows the high-water + mark of those arenas, not SQLite's live heap. + - glibc creates up to eight arenas per core, so on a many-core host RSS + grows with the core count. + - The image sets `MALLOC_ARENA_MAX=2` to bound it. Two arenas cost nothing + here, because database writes already run one at a time. - About 0.2 GiB for everything else in the runtime. -The ceilings add up to 3.7 GiB: the Go heap's 1.5 GiB, SQLite's 2 GiB and the +The ceilings add up to 3.7 GiB: the Go heap's 2.5 GiB, SQLite's 1 GiB and the 0.2 GiB for the rest. Run the container with a memory limit of 5 GiB and swap disabled: @@ -462,38 +496,36 @@ docker run --memory=5g --memory-swap=5g ... or the equivalent in your deployment tool. This leaves headroom above the ceilings for spikes and the kernel page cache. -Override the Go soft limit by setting `GOMEMLIMIT` in the environment (for -example `-e GOMEMLIMIT=1GiB`); this replaces the image default. -`MALLOC_ARENA_MAX` can be overridden the same way, but raising it lets RSS climb -again on a many-core host. +To replace the image's Go soft limit, set `GOMEMLIMIT` in the environment, for +example `-e GOMEMLIMIT=3GiB`. A limit close to the routing table's size makes +the garbage collector run almost all the time. `MALLOC_ARENA_MAX` can be +overridden the same way, but raising it lets RSS climb again on a many-core +host. What happens at each limit: - Go soft limit: as the heap approaches `GOMEMLIMIT`, the runtime runs garbage collection more aggressively rather than growing further. -- SQLite: at the 1.5 GiB soft heap limit it recycles its page cache instead of - allocating more; at the 2 GiB hard heap limit a statement fails with an - out-of-memory error, and the handler logs it and drops that batch. The process - keeps running. +- SQLite: at the 768 MiB soft heap limit it recycles its page cache instead of + allocating more. At the 1 GiB hard heap limit a statement fails with an + out-of-memory error, and the handler logs it. The prefix handler writes the + routing table's changes again on a later write. The other writes drop their + batch. The process keeps running. - Handler queues: each of the four handler queues holds at most 20,000 messages. When a queue fills, the streamer drops messages instead of blocking, except a `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, which 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) and 250,000 for the prefix handler - (`prefixBatchMaxSize`, about 60 MiB). At that size the handler takes nothing - more off its queue until the write ends, so the queue fills and the streamer - drops messages as above. When the delete of a peer's live routes after its - session went down fails, the prefix handler keeps the updates the peer sent - after the down for the next write, which retries the delete first; on top of - its 250,000 it keeps at most the last 25,000 of them (`prefixBatchSize`, about - 6 MiB) and drops and logs the older ones, so while the delete keeps failing - the other peers' updates still come off the queue. With the batches being - written, the three handlers' batches take at most about 0.25 GiB of the Go - heap's 1.5 GiB. + 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 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. @@ -521,21 +553,19 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs: - Stop grace: the container needs 9 seconds between SIGTERM and SIGKILL. upaas gives 10 (`stopTimeoutSeconds` in its `internal/docker/client.go`), as `docker stop` does by default. The daemon allows 8 seconds for the handlers to - write what they hold and for the requests in progress to finish, then stops + write what they hold and for the requests in progress to finish. Then it stops the HTTP server, closes the database and exits with status 0. A longer grace changes nothing. What is not written after 8 seconds is lost, and the streamer - and each handler log how much. When the prefix handler has fallen behind the - feed, a stop loses far more: its batch is full at 250,000 updates and the - streamer's worker for it waits for room, so the streamer's stop takes the - whole 8 seconds and every handler then skips its last write. Lost then are the - messages left on the four handlers' queues, what the AS, peer and peering - handlers hold, the prefix handler's batch, and the write it has in progress, - up to another 250,000 updates, which take about 20 seconds and are cut off by - the exit; withdrawals and peer downs are among them. A route withdrawn in them - stays in the live route table until its peer announces or withdraws the prefix - again or its session goes down. When the 8 seconds run out the database is not - closed, and SQLite rolls back a write the exit cut off when the database next - opens. + and each handler log how much. + - The prefix handler's last write takes at most 20,000 of the routing + table's changed pairs. A full-table peer's session going down changes + about a million pairs, which take about a minute to write. A stop in that + minute leaves the rest unwritten. + - A lost withdrawal or peer down leaves its routes in the `routes` table, + and the next start reads them back. They stay until the peer sends another + update for the prefix, or its session goes down. + - When the 8 seconds run out the database is not closed. When the database + next opens, SQLite rolls back any write the exit cut off. ## Development diff --git a/TODO.md b/TODO.md index 2a3e50a..c095961 100644 --- a/TODO.md +++ b/TODO.md @@ -25,14 +25,23 @@ are measured by a benchmark, not yet on the live feed. # Next Step -Run `next` on the live feed, as README "Running under upaas" describes, for 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 +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-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 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 @@ -277,5 +286,4 @@ tested. # Future Steps -- the rest of https://git.eeqj.de/sneak/routewatch/issues/122: removing the - per-peer route tables +None. diff --git a/internal/database/counts.go b/internal/database/counts.go index 5c8d4a5..033a8f1 100644 --- a/internal/database/counts.go +++ b/internal/database/counts.go @@ -6,11 +6,10 @@ import ( "sync" ) -// liveCounts holds the running row counts and the prefix distribution that the -// stats endpoints report. They are seeded once at startup from the tables and -// then adjusted on every write, so a stats read serves them from memory instead -// of running a query over the tables. The COUNT(*) scans (issue 27) and then -// the prefix distribution query (issue 30) each grew with the database until +// liveCounts holds the running row counts that the stats endpoints report. +// They are seeded once at startup from the tables and then adjusted on every +// write, so a stats read serves them from memory instead of running a query +// over the tables. The COUNT(*) scans (issue 27) grew with the database until // they took the whole request timeout and made /api/v1/stats return 500. // // A single mutex guards all fields so the stats reader takes a consistent @@ -23,20 +22,11 @@ type liveCounts struct { prefixesV6 int peerings int peers int - routesV4 int - routesV6 int - // The prefix distribution: for each mask length, the number of distinct - // prefixes that have at least one live route. - distributionV4 [ipv4Bits + 1]int - distributionV6 [ipv6Bits + 1]int } // seed sets every count to the value read from the tables at startup. It runs // before any writer, so it needs no coordination with the adjust methods. -func (c *liveCounts) seed( - asns, prefixesV4, prefixesV6, peerings, peers, routesV4, routesV6 int, - distributionV4, distributionV6 []PrefixDistribution, -) { +func (c *liveCounts) seed(asns, prefixesV4, prefixesV6, peerings, peers int) { c.mu.Lock() defer c.mu.Unlock() @@ -45,16 +35,6 @@ func (c *liveCounts) seed( c.prefixesV6 = prefixesV6 c.peerings = peerings c.peers = peers - c.routesV4 = routesV4 - c.routesV6 = routesV6 - - for _, entry := range distributionV4 { - addAtMaskLength(c.distributionV4[:], entry.MaskLength, entry.Count) - } - - for _, entry := range distributionV6 { - addAtMaskLength(c.distributionV6[:], entry.MaskLength, entry.Count) - } } // addASNs adds n to the ASN count. @@ -86,41 +66,7 @@ func (c *liveCounts) addPeers(n int) { c.mu.Unlock() } -// addRoutes adds to the IPv4 and IPv6 live-route counts. Deletions pass -// negative values. -func (c *liveCounts) addRoutes(v4, v6 int) { - c.mu.Lock() - c.routesV4 += v4 - c.routesV6 += v6 - c.mu.Unlock() -} - -// addToDistribution adds n to the IPv4 and IPv6 prefix distributions once for -// each listed mask length. A write lists the mask lengths of the prefixes it -// gave their first live route with n = 1, and of the prefixes it left with no -// live route with n = -1. -func (c *liveCounts) addToDistribution(maskLengthsV4, maskLengthsV6 []int, n int) { - c.mu.Lock() - defer c.mu.Unlock() - - for _, maskLength := range maskLengthsV4 { - addAtMaskLength(c.distributionV4[:], maskLength, n) - } - - for _, maskLength := range maskLengthsV6 { - addAtMaskLength(c.distributionV6[:], maskLength, n) - } -} - -// addAtMaskLength adds n to counts[maskLength]. A mask length the array has no -// entry for is ignored, so a malformed route cannot crash the daemon. -func addAtMaskLength(counts []int, maskLength, n int) { - if maskLength >= 0 && maskLength < len(counts) { - counts[maskLength] += n - } -} - -// fill copies the counts into a Stats, including the derived totals, under a +// fill copies the counts into a Stats, including the derived total, under a // single read lock so the reader sees one consistent snapshot. func (c *liveCounts) fill(s *Stats) { c.mu.RLock() @@ -132,26 +78,6 @@ func (c *liveCounts) fill(s *Stats) { s.Prefixes = c.prefixesV4 + c.prefixesV6 s.Peerings = c.peerings s.Peers = c.peers - s.IPv4Routes = c.routesV4 - s.IPv6Routes = c.routesV6 - s.LiveRoutes = c.routesV4 + c.routesV6 - s.IPv4PrefixDistribution = distributionList(c.distributionV4[:]) - s.IPv6PrefixDistribution = distributionList(c.distributionV6[:]) -} - -// distributionList lists the mask lengths that have at least one prefix, in -// ascending order, the way the distribution query returns them. -func distributionList(counts []int) []PrefixDistribution { - var list []PrefixDistribution - - for maskLength, count := range counts { - if count > 0 { - list = append(list, - PrefixDistribution{MaskLength: maskLength, Count: count}) - } - } - - return list } // countRows returns the number of rows in the named table. It is used only at @@ -167,9 +93,8 @@ func (d *Database) countRows(ctx context.Context, table string) (int, error) { return n, nil } -// seedCounts reads the current row counts and prefix distribution from the -// tables into the in-memory counters. It runs once at startup, before the -// streamer begins writing. +// seedCounts reads the current row counts from the tables into the in-memory +// counters. It runs once at startup, before the streamer begins writing. func (d *Database) seedCounts(ctx context.Context) error { asns, err := d.countRows(ctx, "asns") if err != nil { @@ -196,23 +121,7 @@ func (d *Database) seedCounts(ctx context.Context) error { return err } - routesV4, err := d.countRows(ctx, tableLiveRoutesV4) - if err != nil { - return err - } - - routesV6, err := d.countRows(ctx, tableLiveRoutesV6) - if err != nil { - return err - } - - distributionV4, distributionV6, err := d.GetPrefixDistributionContext(ctx) - if err != nil { - return err - } - - d.counts.seed(asns, prefixesV4, prefixesV6, peerings, peers, routesV4, routesV6, - distributionV4, distributionV6) + d.counts.seed(asns, prefixesV4, prefixesV6, peerings, peers) return nil } diff --git a/internal/database/counts_internal_test.go b/internal/database/counts_internal_test.go index 805830d..d2e1d1f 100644 --- a/internal/database/counts_internal_test.go +++ b/internal/database/counts_internal_test.go @@ -3,62 +3,23 @@ package database import ( "context" "fmt" - "slices" "sync" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" - "github.com/google/uuid" ) -// testV4PeerIP is the peer the IPv4 routes in these tests are learned from. +// testV4PeerIP is the peer the IPv4 rows in these tests are learned from. const testV4PeerIP = "192.0.2.1" -// testV6Prefix is the prefix of the IPv6 routes in these tests. +// testV6Prefix is the IPv6 prefix of these tests. const testV6Prefix = "2001:db8::/32" -// mkV4Route builds an IPv4 live route with its mask length taken from the -// prefix. -func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute { - t.Helper() - - maskLength, err := prefixMaskLength(prefix) - if err != nil { - t.Fatalf("prefixMaskLength(%s): %v", prefix, err) - } - - return &LiveRoute{ - Prefix: prefix, - MaskLength: maskLength, - IPVersion: ipVersionV4, - OriginASN: asn, - PeerIP: testV4PeerIP, - ASPath: []int{asn}, - NextHop: "192.0.2.254", - LastUpdated: ts, - } -} - -// mkV6Route builds an IPv6 live route. -func mkV6Route(prefix string, asn int, ts time.Time) *LiveRoute { - return &LiveRoute{ - Prefix: prefix, - MaskLength: 32, - IPVersion: ipVersionV6, - OriginASN: asn, - PeerIP: "2001:db8::1", - ASPath: []int{asn}, - NextHop: "2001:db8::ffff", - LastUpdated: ts, - } -} - // TestLiveCountsTrackWritesInRealtime checks that the stats counts start at -// zero, reflect each write the moment it commits (no recompute, no timer), do -// not move when a route is merely re-announced, and drop when a route is -// deleted. These counts are what /api/v1/stats reports; before this change the +// zero and reflect each write the moment it commits (no recompute, no timer). +// These counts are what /api/v1/stats reports; before this change the // endpoint recomputed them with a COUNT(*) over each table on every request. func TestLiveCountsTrackWritesInRealtime(t *testing.T) { t.Parallel() @@ -79,7 +40,7 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) { } if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 || - empty.Peers != 0 || empty.LiveRoutes != 0 { + empty.Peers != 0 { t.Fatalf("empty database counts nonzero: %+v", empty) } @@ -87,17 +48,6 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) { writeASNsPrefixesAndPeers(t, db, ts) - routes := []*LiveRoute{ - mkV4Route(t, "198.51.100.0/24", 64500, ts), - mkV4Route(t, "203.0.113.0/24", 64501, ts.Add(time.Minute)), - mkV6Route(testV6Prefix, 64502, ts.Add(2*time.Minute)), - } - - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - stats, err := db.GetStatsContext(ctx) if err != nil { t.Fatalf("GetStatsContext: %v", err) @@ -105,46 +55,11 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) { assertCounts(t, "after inserts", stats, wantCounts{ asns: 2, prefixes: 2, peerings: 1, peers: 1, - ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3, - }) - - // Re-announcing the same routes is an update, not an insert: counts hold. - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch (re-announce): %v", err) - } - - stats, err = db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("GetStatsContext: %v", err) - } - - assertCounts(t, "after re-announce", stats, wantCounts{ - asns: 2, prefixes: 2, peerings: 1, peers: 1, - ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3, - }) - - // A withdrawal removes one route. - err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{ - {Prefix: "203.0.113.0/24", PeerIP: testV4PeerIP, IPVersion: ipVersionV4}, - }) - if err != nil { - t.Fatalf("DeleteLiveRouteBatch: %v", err) - } - - stats, err = db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("GetStatsContext: %v", err) - } - - assertCounts(t, "after delete", stats, wantCounts{ - asns: 2, prefixes: 2, peerings: 1, peers: 1, - ipv4Routes: 1, ipv6Routes: 1, liveRoutes: 2, }) } // writeASNsPrefixesAndPeers writes two ASNs, two prefixes, one peer and one -// peering: the rows besides live routes that the stats count. +// peering: the rows the stats count. func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) { t.Helper() @@ -180,8 +95,8 @@ func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) { } // TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same -// database file, and checks the counts and the prefix distribution come back -// from the seed scan rather than starting at zero. +// database file, and checks the counts come back from the seed scan rather +// than starting at zero. func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) { t.Parallel() @@ -192,23 +107,7 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) { t.Fatalf("failed to create database: %v", err) } - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - seen := SeenTimes{First: ts, Last: ts} - - err = db.GetOrCreateASNBatch( - map[int]SeenTimes{64500: seen, 64501: seen, 64502: seen}) - if err != nil { - t.Fatalf("GetOrCreateASNBatch: %v", err) - } - - err = db.UpsertLiveRouteBatch([]*LiveRoute{ - mkV4Route(t, "198.51.100.0/24", 64500, ts), - mkV6Route(testV6Prefix, 64502, ts), - }) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } + writeASNsPrefixesAndPeers(t, db, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) err = db.Close() if err != nil { @@ -226,435 +125,9 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) { t.Fatalf("GetStatsContext after reopen: %v", err) } - if stats.ASNs != 3 { - t.Errorf("seeded ASNs = %d, want 3", stats.ASNs) - } - - if stats.IPv4Routes != 1 || stats.IPv6Routes != 1 || stats.LiveRoutes != 2 { - t.Errorf("seeded routes = (v4 %d, v6 %d, total %d), want (1, 1, 2)", - stats.IPv4Routes, stats.IPv6Routes, stats.LiveRoutes) - } - - assertDistribution(t, "seeded IPv4 distribution", stats.IPv4PrefixDistribution, - []PrefixDistribution{{MaskLength: 24, Count: 1}}) - assertDistribution(t, "seeded IPv6 distribution", stats.IPv6PrefixDistribution, - []PrefixDistribution{{MaskLength: 32, Count: 1}}) -} - -// TestPrefixDistributionTracksWrites checks that the prefix distribution the -// stats read reports stays exact across each kind of live-route write, and that -// after every step it equals what the distribution query reads from the route -// tables. The steps run once through the batch methods the prefix handler uses -// and once through the single-route methods. -func TestPrefixDistributionTracksWrites(t *testing.T) { - t.Parallel() - - steps := prefixDistributionSteps(t) - - for _, batch := range []bool{true, false} { - name := "single-route writes" - if batch { - name = "batch writes" - } - - t.Run(name, func(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ctx := context.Background() - - for _, step := range steps { - err := announceRoutes(db, batch, step.announce) - if err != nil { - t.Fatalf("%s: announce: %v", step.name, err) - } - - err = withdrawRoutes(db, batch, step.withdraw) - if err != nil { - t.Fatalf("%s: withdraw: %v", step.name, err) - } - - stats, err := db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("%s: GetStatsContext: %v", step.name, err) - } - - queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx) - if err != nil { - t.Fatalf("%s: GetPrefixDistributionContext: %v", step.name, err) - } - - assertDistribution(t, step.name+": IPv4 distribution", - stats.IPv4PrefixDistribution, step.wantV4) - assertDistribution(t, step.name+": IPv6 distribution", - stats.IPv6PrefixDistribution, step.wantV6) - assertDistribution(t, step.name+": IPv4 distribution query", - queryV4, step.wantV4) - assertDistribution(t, step.name+": IPv6 distribution query", - queryV6, step.wantV6) - } - }) - } -} - -// prefixDistributionStep is one step of TestPrefixDistributionTracksWrites: -// the routes it announces, then the routes it withdraws, then the -// distributions both the stats and the distribution query must report. -type prefixDistributionStep struct { - name string - announce []*LiveRoute - withdraw []*LiveRoute - wantV4 []PrefixDistribution - wantV6 []PrefixDistribution -} - -// prefixDistributionSteps returns the steps of -// TestPrefixDistributionTracksWrites, in the order they run. -func prefixDistributionSteps(t *testing.T) []prefixDistributionStep { - t.Helper() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - shared := mkV4Route(t, "198.51.100.0/24", 64500, ts) - sharedSecondPeer := mkV4Route(t, "198.51.100.0/24", 64500, ts) - sharedSecondPeer.PeerIP = "192.0.2.2" - other := mkV4Route(t, "203.0.113.0/24", 64501, ts) - wide := mkV4Route(t, "172.16.0.0/16", 64502, ts) - v6 := mkV6Route(testV6Prefix, 64503, ts) - v6SecondPeer := mkV6Route(testV6Prefix, 64503, ts) - v6SecondPeer.PeerIP = "2001:db8::2" - - all := []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 2}} - v6Only := []PrefixDistribution{{MaskLength: 32, Count: 1}} - - return []prefixDistributionStep{ - { - name: "new routes", - announce: []*LiveRoute{shared, other, wide, v6}, - wantV4: all, wantV6: v6Only, - }, - { - name: "re-announcement", - announce: []*LiveRoute{shared, other, wide, v6}, - wantV4: all, wantV6: v6Only, - }, - { - name: "second peer announces a prefix that has a live route", - announce: []*LiveRoute{sharedSecondPeer, v6SecondPeer}, - wantV4: all, wantV6: v6Only, - }, - { - name: "withdrawal of a route that is not the last for its prefix", - withdraw: []*LiveRoute{shared, v6}, - wantV4: all, wantV6: v6Only, - }, - { - name: "withdrawal of the last route for a prefix", - withdraw: []*LiveRoute{sharedSecondPeer, v6SecondPeer}, - wantV4: []PrefixDistribution{ - {MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 1}, - }, - }, - { - name: "withdrawal of every remaining route", - withdraw: []*LiveRoute{other, wide}, - }, - { - name: "two peers announce a new prefix together", - announce: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer}, - wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}}, - wantV6: v6Only, - }, - { - name: "both routes for a prefix withdrawn together", - withdraw: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer}, - }, - // The feed often withdraws a route that is not live. That must not take - // the prefix out of the distribution. A count wrongly taken below zero is - // left out of the answer, so the next step shows it: its announcement - // at the same mask length would then not be counted. - { - name: "withdrawal of routes that are not live", - withdraw: []*LiveRoute{other, v6}, - }, - { - name: "announcement after a withdrawal of routes that are not live", - announce: []*LiveRoute{other, v6}, - wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}}, - wantV6: v6Only, - }, - } -} - -// announceRoutes writes routes in one UpsertLiveRouteBatch, or with one -// UpsertLiveRoute each. -func announceRoutes(db *Database, batch bool, routes []*LiveRoute) error { - if batch { - return db.UpsertLiveRouteBatch(routes) - } - - for _, route := range routes { - err := db.UpsertLiveRoute(route) - if err != nil { - return err - } - } - - return nil -} - -// withdrawRoutes removes routes in one DeleteLiveRouteBatch, or with one -// DeleteLiveRoute each. -func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error { - if !batch { - for _, route := range routes { - err := db.DeleteLiveRoute(route.Prefix, route.PeerIP) - if err != nil { - return err - } - } - - return nil - } - - deletions := make([]LiveRouteDeletion, 0, len(routes)) - for _, route := range routes { - deletions = append(deletions, LiveRouteDeletion{ - Prefix: route.Prefix, PeerIP: route.PeerIP, IPVersion: route.IPVersion, - }) - } - - return db.DeleteLiveRouteBatch(deletions) -} - -// TestDeleteLiveRoutesOfPeer gives two peers IPv4 and IPv6 live routes, some -// for the same prefixes, and deletes all of one peer's routes. The other peer's -// routes stay, and the route counts and prefix distribution held in memory -// equal what is read from the route tables. -func TestDeleteLiveRoutesOfPeer(t *testing.T) { - t.Parallel() - - const ( - peerIP = "192.0.2.1" - otherPeerIP = "192.0.2.2" - sharedV4 = "198.51.100.0/24" - sharedV6 = "2001:db8::/32" - ) - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - ofPeer := func(peer string, route *LiveRoute) *LiveRoute { - route.PeerIP = peer - - return route - } - onlyV6 := mkV6Route("2001:db8:1::/48", 64503, ts) - onlyV6.MaskLength = 48 - - err = db.UpsertLiveRouteBatch([]*LiveRoute{ - ofPeer(peerIP, mkV4Route(t, sharedV4, 64500, ts)), - ofPeer(otherPeerIP, mkV4Route(t, sharedV4, 64500, ts)), - ofPeer(peerIP, mkV4Route(t, "203.0.113.0/24", 64501, ts)), - ofPeer(peerIP, mkV6Route(sharedV6, 64502, ts)), - ofPeer(otherPeerIP, mkV6Route(sharedV6, 64502, ts)), - ofPeer(peerIP, onlyV6), + assertCounts(t, "after reopen", stats, wantCounts{ + asns: 2, prefixes: 2, peerings: 1, peers: 1, }) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - // peerIP has two IPv4 and two IPv6 routes; a second delete finds none. - for _, want := range []int{4, 0} { - deleted, err := db.DeleteLiveRoutesOfPeer(peerIP) - if err != nil { - t.Fatalf("DeleteLiveRoutesOfPeer: %v", err) - } - - if deleted != want { - t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d", - deleted, want) - } - } - - for _, prefix := range []string{sharedV4, sharedV6} { - routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix) - if err != nil { - t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err) - } - - if len(routes) != 1 || routes[0].PeerIP != otherPeerIP { - t.Errorf("live routes for %s = %+v, want only the one from %s", - prefix, routes, otherPeerIP) - } - } - - checkOneLiveRoutePerIPVersion(t, db) -} - -// TestDeleteLiveRoutesOfPeerDeletesInChunks gives a peer one IPv4 route more -// than a chunk holds, two of them for prefixes another peer also has. One chunk -// deletes peerRouteChunkSize of the peer's routes and commits, leaving the last -// one. The routes are written again, and DeleteLiveRoutesOfPeer deletes all of -// them, a chunk and then the last one. After each step the route count and -// prefix distribution held in memory and those read from the route table are -// the ones wanted. -func TestDeleteLiveRoutesOfPeerDeletesInChunks(t *testing.T) { - t.Parallel() - - const ( - peerIP = "192.0.2.1" - otherPeerIP = "192.0.2.2" - // How many of the peer's prefixes the other peer also has, the first - // ones in prefix order. - sharedPrefixes = 2 - ) - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - peerRoutes := make([]*LiveRoute, 0, peerRouteChunkSize+1) - otherPeerRoutes := make([]*LiveRoute, 0, sharedPrefixes) - - for i := range peerRouteChunkSize + 1 { - prefix := fmt.Sprintf("10.%d.%d.0/24", i/256, i%256) - - route := mkV4Route(t, prefix, 64500, ts) - route.PeerIP = peerIP - peerRoutes = append(peerRoutes, route) - - if i < sharedPrefixes { - route := mkV4Route(t, prefix, 64500, ts) - route.PeerIP = otherPeerIP - otherPeerRoutes = append(otherPeerRoutes, route) - } - } - - err = db.UpsertLiveRouteBatch(slices.Concat(peerRoutes, otherPeerRoutes)) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - deletedV4, deletedV6, err := db.deletePeerRouteChunk(peerIP) - if err != nil { - t.Fatalf("deletePeerRouteChunk: %v", err) - } - - if deletedV4 != peerRouteChunkSize || deletedV6 != 0 { - t.Errorf("deletePeerRouteChunk deleted (%d, %d) routes, want (%d, 0)", - deletedV4, deletedV6, peerRouteChunkSize) - } - - // The shared prefixes went with the chunk, so each route left has a - // prefix of its own. - checkV4RoutesOfOwnPrefixes(t, db, "after one chunk", 1+sharedPrefixes) - - err = db.UpsertLiveRouteBatch(peerRoutes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - deleted, err := db.DeleteLiveRoutesOfPeer(peerIP) - if err != nil { - t.Fatalf("DeleteLiveRoutesOfPeer: %v", err) - } - - if deleted != len(peerRoutes) { - t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d", - deleted, len(peerRoutes)) - } - - checkV4RoutesOfOwnPrefixes(t, db, "after DeleteLiveRoutesOfPeer", - sharedPrefixes) -} - -// checkV4RoutesOfOwnPrefixes checks that the database has wantRoutes IPv4 live -// routes, each for a /24 no other live route has, both as counted in memory and -// as read from the route table. when says which step of the test checks. -func checkV4RoutesOfOwnPrefixes( - t *testing.T, db *Database, when string, wantRoutes int, -) { - t.Helper() - - ctx := context.Background() - - stats, err := db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("GetStatsContext: %v", err) - } - - tableRoutes, _, err := db.GetLiveRouteCountsContext(ctx) - if err != nil { - t.Fatalf("GetLiveRouteCountsContext: %v", err) - } - - if stats.IPv4Routes != wantRoutes || tableRoutes != wantRoutes { - t.Errorf("%s: IPv4 live routes: %d in memory, %d in the table, want %d", - when, stats.IPv4Routes, tableRoutes, wantRoutes) - } - - queryDistribution, _, err := db.GetPrefixDistributionContext(ctx) - if err != nil { - t.Fatalf("GetPrefixDistributionContext: %v", err) - } - - want := []PrefixDistribution{{MaskLength: 24, Count: wantRoutes}} - assertDistribution(t, when+": IPv4 distribution", - stats.IPv4PrefixDistribution, want) - assertDistribution(t, when+": IPv4 distribution query", - queryDistribution, want) -} - -// checkOneLiveRoutePerIPVersion checks that the database has one IPv4 live -// route, for a /24, and one IPv6 live route, for a /32, both as counted in -// memory and as read from the route tables. -func checkOneLiveRoutePerIPVersion(t *testing.T, db *Database) { - t.Helper() - - ctx := context.Background() - - stats, err := db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("GetStatsContext: %v", err) - } - - tableV4, tableV6, err := db.GetLiveRouteCountsContext(ctx) - if err != nil { - t.Fatalf("GetLiveRouteCountsContext: %v", err) - } - - if stats.IPv4Routes != 1 || tableV4 != 1 || stats.IPv6Routes != 1 || - tableV6 != 1 { - t.Errorf("live routes (IPv4, IPv6): (%d, %d) in memory, (%d, %d) in "+ - "the tables, want (1, 1)", - stats.IPv4Routes, stats.IPv6Routes, tableV4, tableV6) - } - - queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx) - if err != nil { - t.Fatalf("GetPrefixDistributionContext: %v", err) - } - - wantV4 := []PrefixDistribution{{MaskLength: 24, Count: 1}} - wantV6 := []PrefixDistribution{{MaskLength: 32, Count: 1}} - - assertDistribution(t, "IPv4 distribution", stats.IPv4PrefixDistribution, wantV4) - assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution, wantV6) - assertDistribution(t, "IPv4 distribution query", queryV4, wantV4) - assertDistribution(t, "IPv6 distribution query", queryV6, wantV6) } // TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so @@ -672,7 +145,10 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) { ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - const writers = 4 + const ( + writers = 4 + prefixesPerWrite = 25 + ) var wg sync.WaitGroup wg.Add(writers) @@ -681,14 +157,12 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) { go func(base int) { defer wg.Done() - for i := range 25 { - asn := 65000 + base*100 + i - route := mkV6Route(testV6Prefix, asn, ts) - route.PeerIP = "2001:db8::" + uuid.NewString() + for i := range prefixesPerWrite { + prefix := fmt.Sprintf("10.%d.%d.0/24", base, i) - err := db.UpsertLiveRoute(route) + err := db.UpdatePrefixesBatch(map[string]time.Time{prefix: ts}) if err != nil { - t.Errorf("UpsertLiveRoute: %v", err) + t.Errorf("UpdatePrefixesBatch: %v", err) return } @@ -728,22 +202,16 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) { t.Fatalf("GetStatsContext: %v", err) } - if want := writers * 25; stats.IPv6Routes != want { - t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, want) + if want := writers * prefixesPerWrite; stats.IPv4Prefixes != want { + t.Errorf("IPv4Prefixes = %d, want %d", stats.IPv4Prefixes, want) } - // Every writer announced the same prefix, so it counts once. - assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution, - []PrefixDistribution{{MaskLength: 32, Count: 1}}) } type wantCounts struct { - asns int - prefixes int - peerings int - peers int - ipv4Routes int - ipv6Routes int - liveRoutes int + asns int + prefixes int + peerings int + peers int } func assertCounts(t *testing.T, when string, got Stats, want wantCounts) { @@ -764,24 +232,4 @@ func assertCounts(t *testing.T, when string, got Stats, want wantCounts) { if got.Peers != want.peers { t.Errorf("%s: Peers = %d, want %d", when, got.Peers, want.peers) } - - if got.IPv4Routes != want.ipv4Routes { - t.Errorf("%s: IPv4Routes = %d, want %d", when, got.IPv4Routes, want.ipv4Routes) - } - - if got.IPv6Routes != want.ipv6Routes { - t.Errorf("%s: IPv6Routes = %d, want %d", when, got.IPv6Routes, want.ipv6Routes) - } - - if got.LiveRoutes != want.liveRoutes { - t.Errorf("%s: LiveRoutes = %d, want %d", when, got.LiveRoutes, want.liveRoutes) - } -} - -func assertDistribution(t *testing.T, what string, got, want []PrefixDistribution) { - t.Helper() - - if !slices.Equal(got, want) { - t.Errorf("%s = %v, want %v", what, got, want) - } } diff --git a/internal/database/database.go b/internal/database/database.go index 5bb7a41..4988e69 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,16 +1,12 @@ package database import ( - "cmp" "context" "database/sql" _ "embed" - "encoding/json" "errors" "fmt" "maps" - "math/rand/v2" - "net" "net/netip" "os" "path/filepath" @@ -21,7 +17,6 @@ import ( "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" - "git.eeqj.de/sneak/routewatch/internal/ristypes" "git.eeqj.de/sneak/routewatch/pkg/asinfo" _ "github.com/mattn/go-sqlite3" // CGO SQLite driver ) @@ -36,25 +31,12 @@ const ( dirPermissions = 0750 // rwxr-x--- ipVersionV4 = 4 ipVersionV6 = 6 - ipv4Bits = 32 - ipv6Bits = 128 - - tableLiveRoutesV4 = "live_routes_v4" - tableLiveRoutesV6 = "live_routes_v6" // Names of the IP versions in error and log messages. ipv4Family = "IPv4" ipv6Family = "IPv6" ) -// The IP lookup does not use routes shorter than these mask lengths. A default -// route or a route this short only says where a peer sends traffic it has no -// better route for; it does not say who holds the address. -const ( - minLookupMaskLengthV4 = 8 - minLookupMaskLengthV6 = 16 -) - // SQLite memory tuning (README "Memory"). cache_size and busy_timeout go in the // DSN so every pooled connection gets them; the connection kept for writing // then sets its own cache size. The heap limits are process-wide and set once. @@ -68,18 +50,19 @@ const ( // since its last read, many times a minute, so a large one is wasted. sqliteReadCacheSizeKiB = -16384 // sqliteWriteCacheSizeKiB is the page cache of the connection kept for - // writing, -1048576 = 1 GiB. It keeps its pages from one write to the next, - // so the index pages the route writes touch stay in memory. - sqliteWriteCacheSizeKiB = -1048576 + // writing, -524288 = 512 MiB. It keeps its pages from one write to the + // next. The routes table of a full routing table takes about 450 MB, so + // the pages the writes of the table's changes touch stay in memory. + sqliteWriteCacheSizeKiB = -524288 // sqliteBusyTimeoutMs is how long a connection waits on a locked database. sqliteBusyTimeoutMs = 5000 - // sqliteSoftHeapLimitBytes (1.5 GiB) makes SQLite recycle its cache rather + // sqliteSoftHeapLimitBytes (768 MiB) makes SQLite recycle its cache rather // than allocate once its C heap passes this size. It is above the caches - // together, so it does not shrink them. - sqliteSoftHeapLimitBytes = 1610612736 - // sqliteHardHeapLimitBytes (2 GiB) fails a statement with SQLITE_NOMEM + // together, 656 MiB, so it does not shrink them. + sqliteSoftHeapLimitBytes = 805306368 + // sqliteHardHeapLimitBytes (1 GiB) fails a statement with SQLITE_NOMEM // instead of growing the C heap without bound. - sqliteHardHeapLimitBytes = 2147483648 + sqliteHardHeapLimitBytes = 1073741824 ) // Settings that bound the WAL file's size once no read is open (see @@ -111,23 +94,8 @@ const ( optimizeAllTables = 0x10002 ) -// Bounds on the work GetRandomPrefixesByLength does to pick its sample. -const ( - // maxPrefixesToShuffle is the most prefixes of one mask length that are - // read in full and shuffled to pick the sample. A mask length with more is - // sampled with random lookups. - maxPrefixesToShuffle = 5000 - // sampleLookupsPerPrefix is the most random lookups made for each prefix - // asked for. - sampleLookupsPerPrefix = 4 -) - // Common errors var ( - // ErrInvalidIP is returned when an IP address is malformed - ErrInvalidIP = errors.New("invalid IP address") - // ErrNoRoute is returned when no route is found for an IP - ErrNoRoute = errors.New("no route found") // ErrNoStaleASN is returned when no ASN needs WHOIS refresh ErrNoStaleASN = errors.New("no stale ASN found") // ErrCheckpointIncomplete is returned by Checkpoint when it left part of @@ -332,511 +300,6 @@ func (d *Database) Close() error { return errors.Join(d.writer.Close(), d.db.Close()) } -// A live-route upsert sets the origin AS of the peer's route for the prefix -// when the announcement changes it, then updates the rest of the route, and -// inserts the route only when that update matched no row. A live route is keyed -// by prefix and peer, so an announcement replaces the peer's route for the -// prefix whatever its origin AS. The update's rows-affected count (1 for an -// existing key, 0 for a new one) is what lets the in-memory route counters stay -// exact without a COUNT(*). Callers hold the database write lock, so no other -// writer can insert the same key between the statements. -// -// SQLite rewrites a row's entry in every index on a column an UPDATE sets, even -// when the value stays the same, so the update sets only the columns that -// change: mask_length follows from the prefix, and origin_asn has a statement -// of its own that changes nothing when the origin AS stays the same, as it -// mostly does. -const ( - updateLiveRouteOriginV4SQL = `UPDATE live_routes_v4 SET origin_asn = ? - WHERE prefix = ? AND peer_ip = ? AND origin_asn != ?` - - updateLiveRouteV4SQL = `UPDATE live_routes_v4 - SET as_path = ?, next_hop = ?, last_updated = ? - WHERE prefix = ? AND peer_ip = ?` - - insertLiveRouteV4SQL = `INSERT INTO live_routes_v4 - (prefix, mask_length, origin_asn, peer_ip, as_path, next_hop, - last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)` - - updateLiveRouteOriginV6SQL = `UPDATE live_routes_v6 SET origin_asn = ? - WHERE prefix = ? AND peer_ip = ? AND origin_asn != ?` - - updateLiveRouteV6SQL = `UPDATE live_routes_v6 - SET as_path = ?, next_hop = ?, last_updated = ? - WHERE prefix = ? AND peer_ip = ?` - - insertLiveRouteV6SQL = `INSERT INTO live_routes_v6 - (prefix, mask_length, origin_asn, peer_ip, as_path, next_hop, - last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)` -) - -// Before a new route is inserted and after a route is deleted, the write looks -// up whether any live route has that prefix, to keep the in-memory prefix -// distribution exact. The lookup reads one entry of the prefix index. -const ( - prefixHasLiveRouteV4SQL = `SELECT EXISTS - (SELECT 1 FROM live_routes_v4 WHERE prefix = ?)` - prefixHasLiveRouteV6SQL = `SELECT EXISTS - (SELECT 1 FROM live_routes_v6 WHERE prefix = ?)` -) - -// A deletion removes the live route the peer has for the prefix, if any. -const ( - deleteLiveRouteV4SQL = `DELETE FROM live_routes_v4 - WHERE prefix = ? AND peer_ip = ?` - deleteLiveRouteV6SQL = `DELETE FROM live_routes_v6 - WHERE prefix = ? AND peer_ip = ?` -) - -// upsertRouteRow updates a live route with the origin and update statements of -// its IP version's table, inserting it when no row matched. It reports whether -// a new row was inserted and whether that row is the first live route for its -// prefix. -func upsertRouteRow( - ctx context.Context, setOrigin, upd, ins, has *sql.Stmt, route *LiveRoute, - pathJSON string, -) (bool, bool, error) { - _, err := setOrigin.ExecContext(ctx, route.OriginASN, route.Prefix, - route.PeerIP, route.OriginASN) - if err != nil { - return false, false, err - } - - res, err := upd.ExecContext(ctx, pathJSON, route.NextHop, route.LastUpdated, - route.Prefix, route.PeerIP) - if err != nil { - return false, false, err - } - - affected, err := res.RowsAffected() - if err != nil { - return false, false, err - } - - if affected > 0 { - return false, false, nil - } - - var prefixHadRoute bool - - err = has.QueryRowContext(ctx, route.Prefix).Scan(&prefixHadRoute) - if err != nil { - return false, false, err - } - - _, err = ins.ExecContext(ctx, route.Prefix, route.MaskLength, - route.OriginASN, route.PeerIP, pathJSON, route.NextHop, - route.LastUpdated) - if err != nil { - return false, false, err - } - - return true, !prefixHadRoute, nil -} - -// upsertRoutesInTable upserts routes, all of one IP version, in the live route -// table of that version. It returns how many new rows it inserted and the mask -// lengths of the prefixes that got their first live route. -func upsertRoutesInTable( - ctx context.Context, tx *loggingTx, routes []*LiveRoute, ipVersion int, -) (int, []int, error) { - if len(routes) == 0 { - return 0, nil, nil - } - - family, originSQL, updateSQL := ipv4Family, updateLiveRouteOriginV4SQL, - updateLiveRouteV4SQL - insertSQL, lookupSQL := insertLiveRouteV4SQL, prefixHasLiveRouteV4SQL - - if ipVersion == ipVersionV6 { - family, originSQL, updateSQL = ipv6Family, updateLiveRouteOriginV6SQL, - updateLiveRouteV6SQL - insertSQL, lookupSQL = insertLiveRouteV6SQL, prefixHasLiveRouteV6SQL - } - - setOrigin, err := tx.PrepareContext(ctx, originSQL) - if err != nil { - return 0, nil, fmt.Errorf("failed to prepare %s origin statement: %w", - family, err) - } - defer func() { _ = setOrigin.Close() }() - - upd, err := tx.PrepareContext(ctx, updateSQL) - if err != nil { - return 0, nil, fmt.Errorf("failed to prepare %s update statement: %w", - family, err) - } - defer func() { _ = upd.Close() }() - - ins, err := tx.PrepareContext(ctx, insertSQL) - if err != nil { - return 0, nil, fmt.Errorf("failed to prepare %s insert statement: %w", - family, err) - } - defer func() { _ = ins.Close() }() - - has, err := tx.PrepareContext(ctx, lookupSQL) - if err != nil { - return 0, nil, fmt.Errorf( - "failed to prepare %s prefix lookup statement: %w", family, err) - } - defer func() { _ = has.Close() }() - - var newRows int - // Mask lengths of the prefixes that get their first live route. - var newPrefixMaskLengths []int - - for _, route := range routes { - pathJSON, err := json.Marshal(route.ASPath) - if err != nil { - return 0, nil, fmt.Errorf("failed to encode AS path: %w", err) - } - - inserted, newPrefix, err := upsertRouteRow(ctx, setOrigin, upd, ins, has, - route, string(pathJSON)) - if err != nil { - return 0, nil, fmt.Errorf("failed to upsert route %s: %w", - route.Prefix, err) - } - - if inserted { - newRows++ - } - - if newPrefix { - newPrefixMaskLengths = append(newPrefixMaskLengths, route.MaskLength) - } - } - - return newRows, newPrefixMaskLengths, nil -} - -// UpsertLiveRouteBatch inserts or updates multiple live routes in a single -// transaction, the IPv4 routes first and then the IPv6 routes. -func (d *Database) UpsertLiveRouteBatch(routes []*LiveRoute) error { - if len(routes) == 0 { - return nil - } - - d.lock("UpsertLiveRouteBatch") - defer d.unlock() - - tx, err := d.beginTx() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer d.rollback(tx) - - // The rows are written in key order, peer then prefix, so consecutive writes - // land on the same index pages. The sort is stable, so of two updates of - // one route the later one is still written last. - sorted := slices.SortedStableFunc(slices.Values(routes), - func(a, b *LiveRoute) int { - return cmp.Or(cmp.Compare(a.PeerIP, b.PeerIP), - cmp.Compare(a.Prefix, b.Prefix)) - }) - - var routesV4, routesV6 []*LiveRoute - - for _, route := range sorted { - if route.IPVersion == ipVersionV4 { - routesV4 = append(routesV4, route) - } else { - routesV6 = append(routesV6, route) - } - } - - ctx := context.Background() - - newV4, newPrefixMaskLengthsV4, err := upsertRoutesInTable(ctx, tx, - routesV4, ipVersionV4) - if err != nil { - return err - } - - newV6, newPrefixMaskLengthsV6, err := upsertRoutesInTable(ctx, tx, - routesV6, ipVersionV6) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - d.counts.addRoutes(newV4, newV6) - d.counts.addToDistribution(newPrefixMaskLengthsV4, newPrefixMaskLengthsV6, 1) - - return nil -} - -// deleteRouteRow runs one deletion with the delete statement of its IP -// version's table. It returns how many rows it removed and whether that left -// the prefix with no live route. -func deleteRouteRow( - ctx context.Context, deleteStmt, has *sql.Stmt, del LiveRouteDeletion, -) (int64, bool, error) { - res, err := deleteStmt.ExecContext(ctx, del.Prefix, del.PeerIP) - if err != nil { - return 0, false, fmt.Errorf("failed to delete route %s: %w", - del.Prefix, err) - } - - // The feed often withdraws a route that is not live, which deletes - // nothing, so the in-memory route counters use the rows-affected count. - affected, err := res.RowsAffected() - if err != nil { - return 0, false, fmt.Errorf("failed to count deleted route %s: %w", - del.Prefix, err) - } - - if affected == 0 { - return 0, false, nil - } - - // The prefix leaves the distribution when no live route has it any more. - var prefixHasRoute bool - - err = has.QueryRowContext(ctx, del.Prefix).Scan(&prefixHasRoute) - if err != nil { - return 0, false, fmt.Errorf("failed to look up prefix %s: %w", - del.Prefix, err) - } - - return affected, !prefixHasRoute, nil -} - -// deleteRoutesInTable runs deletions, all of one IP version, on the live route -// table of that version. It returns how many rows it removed and the mask -// lengths of the prefixes it left with no live route. -func deleteRoutesInTable( - ctx context.Context, tx *loggingTx, deletions []LiveRouteDeletion, - ipVersion int, -) (int64, []int, error) { - if len(deletions) == 0 { - return 0, nil, nil - } - - family, deleteSQL, lookupSQL := ipv4Family, deleteLiveRouteV4SQL, - prefixHasLiveRouteV4SQL - if ipVersion == ipVersionV6 { - family, deleteSQL, lookupSQL = ipv6Family, deleteLiveRouteV6SQL, - prefixHasLiveRouteV6SQL - } - - deleteStmt, err := tx.PrepareContext(ctx, deleteSQL) - if err != nil { - return 0, nil, fmt.Errorf("failed to prepare %s delete statement: %w", - family, err) - } - defer func() { _ = deleteStmt.Close() }() - - has, err := tx.PrepareContext(ctx, lookupSQL) - if err != nil { - return 0, nil, fmt.Errorf( - "failed to prepare %s prefix lookup statement: %w", family, err) - } - defer func() { _ = has.Close() }() - - var deleted int64 - // Mask lengths of the prefixes left with no live route. - var gonePrefixMaskLengths []int - - for _, del := range deletions { - affected, prefixGone, err := deleteRouteRow(ctx, deleteStmt, has, del) - if err != nil { - return 0, nil, err - } - - deleted += affected - - if !prefixGone { - continue - } - - maskLength, err := prefixMaskLength(del.Prefix) - if err != nil { - return 0, nil, err - } - - gonePrefixMaskLengths = append(gonePrefixMaskLengths, maskLength) - } - - return deleted, gonePrefixMaskLengths, nil -} - -// DeleteLiveRouteBatch deletes multiple live routes in a single transaction, -// the IPv4 routes first and then the IPv6 routes. -func (d *Database) DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error { - if len(deletions) == 0 { - return nil - } - - d.lock("DeleteLiveRouteBatch") - defer d.unlock() - - tx, err := d.beginTx() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer d.rollback(tx) - - // The rows are deleted in key order, peer then prefix, as - // UpsertLiveRouteBatch writes them. - sorted := slices.SortedFunc(slices.Values(deletions), - func(a, b LiveRouteDeletion) int { - return cmp.Or(cmp.Compare(a.PeerIP, b.PeerIP), - cmp.Compare(a.Prefix, b.Prefix)) - }) - - var deletionsV4, deletionsV6 []LiveRouteDeletion - - for _, del := range sorted { - if del.IPVersion == ipVersionV4 { - deletionsV4 = append(deletionsV4, del) - } else { - deletionsV6 = append(deletionsV6, del) - } - } - - ctx := context.Background() - - deletedV4, gonePrefixMaskLengthsV4, err := deleteRoutesInTable(ctx, tx, - deletionsV4, ipVersionV4) - if err != nil { - return err - } - - deletedV6, gonePrefixMaskLengthsV6, err := deleteRoutesInTable(ctx, tx, - deletionsV6, ipVersionV6) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - d.counts.addRoutes(-int(deletedV4), -int(deletedV6)) - d.counts.addToDistribution(gonePrefixMaskLengthsV4, gonePrefixMaskLengthsV6, -1) - - return nil -} - -// peerRouteChunkSize is the most routes of a peer that one transaction of -// DeleteLiveRoutesOfPeer deletes from each route table. A peer can have a -// million routes; deleted in one transaction, they would hold the database lock -// for most of a minute. -const peerRouteChunkSize = 10000 - -// Deleting a chunk of a peer's live routes. The first query of each table -// selects the peer's first routes in prefix order, through the -// UNIQUE(peer_ip, prefix) key, each with its mask length and whether another -// peer has a live route for its prefix: the delete takes the prefixes no other -// peer has out of the prefix distribution. The second query deletes the peer's -// routes up to the last prefix the first one selected. -const ( - peerRouteChunkV4SQL = `SELECT r.prefix, r.mask_length, - EXISTS (SELECT 1 FROM live_routes_v4 AS o - WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip) - FROM live_routes_v4 AS r WHERE r.peer_ip = ? - ORDER BY r.prefix LIMIT ?` - deletePeerRouteChunkV4SQL = `DELETE FROM live_routes_v4 - WHERE peer_ip = ? AND prefix <= ?` - - peerRouteChunkV6SQL = `SELECT r.prefix, r.mask_length, - EXISTS (SELECT 1 FROM live_routes_v6 AS o - WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip) - FROM live_routes_v6 AS r WHERE r.peer_ip = ? - ORDER BY r.prefix LIMIT ?` - deletePeerRouteChunkV6SQL = `DELETE FROM live_routes_v6 - WHERE peer_ip = ? AND prefix <= ?` -) - -// deletePeerRoutes deletes the first peerRouteChunkSize live routes of peerIP, -// or as many as it has, from one table, given that table's two statements -// above. It returns how many routes it deleted and the mask length of each -// prefix it left with no live route. -func deletePeerRoutes( - ctx context.Context, tx *loggingTx, chunkSQL, deleteSQL, peerIP string, -) (int64, []int, error) { - rows, err := tx.QueryContext(ctx, chunkSQL, peerIP, peerRouteChunkSize) - if err != nil { - return 0, nil, err - } - defer func() { _ = rows.Close() }() - - var ( - selected int - lastPrefix string - goneMaskLengths []int - ) - - for rows.Next() { - var ( - maskLength int - otherPeerHasARoute bool - ) - - err := rows.Scan(&lastPrefix, &maskLength, &otherPeerHasARoute) - if err != nil { - return 0, nil, err - } - - selected++ - - if !otherPeerHasARoute { - goneMaskLengths = append(goneMaskLengths, maskLength) - } - } - - err = rows.Err() - if err != nil { - return 0, nil, err - } - - if selected == 0 { - return 0, nil, nil - } - - res, err := tx.ExecContext(ctx, deleteSQL, peerIP, lastPrefix) - if err != nil { - return 0, nil, err - } - - deleted, err := res.RowsAffected() - if err != nil { - return 0, nil, err - } - - return deleted, goneMaskLengths, nil -} - -// DeleteLiveRoutesOfPeer deletes every live route of the peer, IPv4 and IPv6, -// as when the peer's BGP session goes down. It deletes them a chunk at a time -// with deletePeerRouteChunk, each chunk in a transaction of its own, and -// releases the database lock between chunks so other writes go on. It returns -// how many routes it deleted; after an error, the chunks it deleted before stay -// deleted. -func (d *Database) DeleteLiveRoutesOfPeer(peerIP string) (int, error) { - total := 0 - - for { - deletedV4, deletedV6, err := d.deletePeerRouteChunk(peerIP) - if err != nil { - return total, err - } - - total += deletedV4 + deletedV6 - - if deletedV4 < peerRouteChunkSize && deletedV6 < peerRouteChunkSize { - return total, nil - } - } -} - // insertPrefixesInTable inserts the prefixes, all of one IP version, that the // prefix table of that version does not hold yet, each first seen at its time // in prefixes. A prefix already stored is left as it is. It returns how many it @@ -868,7 +331,8 @@ func insertPrefixesInTable( var newPrefixes int - // The rows are written in key order, as UpsertLiveRouteBatch writes routes. + // The rows are written in key order, so that consecutive writes land on + // the same index pages. for _, prefix := range slices.Sorted(maps.Keys(prefixes)) { res, err := insertStmt.ExecContext(ctx, prefix, prefixes[prefix]) if err != nil { @@ -1450,15 +914,13 @@ func (d *Database) GetStats() (Stats, error) { // GetStatsContext returns database statistics with context support. // -// The row counts (ASNs, prefixes, peerings, peers, live routes) and the prefix -// distribution come from the in-memory counters, seeded at startup and kept -// current on every write, and the file size from a stat(). The read touches -// no table. +// The row counts (ASNs, prefixes, peerings, peers) come from the in-memory +// counters, seeded at startup and kept current on every write, and the file +// size from a stat(). The read touches no table. func (d *Database) GetStatsContext(_ context.Context) (Stats, error) { var stats Stats - // Row counts and prefix distribution from memory, as a single consistent - // snapshot. + // Row counts from memory, as a single consistent snapshot. d.counts.fill(&stats) // Database file size is a cheap stat() on the file. @@ -1472,321 +934,6 @@ func (d *Database) GetStatsContext(_ context.Context) (Stats, error) { return stats, nil } -// UpsertLiveRoute inserts or updates a live route -func (d *Database) UpsertLiveRoute(route *LiveRoute) error { - d.lock("UpsertLiveRoute") - defer d.unlock() - - pathJSON, err := json.Marshal(route.ASPath) - if err != nil { - return fmt.Errorf("failed to encode AS path: %w", err) - } - - originSQL, updateSQL := updateLiveRouteOriginV4SQL, updateLiveRouteV4SQL - insertSQL, lookupSQL := insertLiveRouteV4SQL, prefixHasLiveRouteV4SQL - - if route.IPVersion == ipVersionV6 { - originSQL, updateSQL = updateLiveRouteOriginV6SQL, updateLiveRouteV6SQL - insertSQL, lookupSQL = insertLiveRouteV6SQL, prefixHasLiveRouteV6SQL - } - - ctx := context.Background() - - // The write lock is held, so no other writer can insert this key between the - // update and the insert even though they are separate autocommit statements. - setOrigin, err := d.writer.PrepareContext(ctx, originSQL) - if err != nil { - return fmt.Errorf("failed to prepare origin statement: %w", err) - } - defer func() { _ = setOrigin.Close() }() - - upd, err := d.writer.PrepareContext(ctx, updateSQL) - if err != nil { - return fmt.Errorf("failed to prepare update statement: %w", err) - } - defer func() { _ = upd.Close() }() - - ins, err := d.writer.PrepareContext(ctx, insertSQL) - if err != nil { - return fmt.Errorf("failed to prepare insert statement: %w", err) - } - defer func() { _ = ins.Close() }() - - has, err := d.writer.PrepareContext(ctx, lookupSQL) - if err != nil { - return fmt.Errorf("failed to prepare prefix lookup statement: %w", err) - } - defer func() { _ = has.Close() }() - - inserted, newPrefix, err := upsertRouteRow(ctx, setOrigin, upd, ins, has, - route, string(pathJSON)) - if err != nil { - return fmt.Errorf("failed to upsert route %s: %w", route.Prefix, err) - } - - if inserted { - if route.IPVersion == ipVersionV4 { - d.counts.addRoutes(1, 0) - } else { - d.counts.addRoutes(0, 1) - } - } - - if newPrefix { - if route.IPVersion == ipVersionV4 { - d.counts.addToDistribution([]int{route.MaskLength}, nil, 1) - } else { - d.counts.addToDistribution(nil, []int{route.MaskLength}, 1) - } - } - - return nil -} - -// DeleteLiveRoute deletes the live route the peer has for the prefix -func (d *Database) DeleteLiveRoute(prefix, peerIP string) error { - d.lock("DeleteLiveRoute") - defer d.unlock() - - // Determine table based on prefix IP version - _, ipnet, err := net.ParseCIDR(prefix) - if err != nil { - return fmt.Errorf("invalid prefix format: %w", err) - } - - isV4 := ipnet.IP.To4() != nil - - // The delete and the prefix lookup after it share one transaction, so the - // in-memory counts change only once both have succeeded. - tx, err := d.beginTx() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer d.rollback(tx) - - // Literal per-table queries (rather than one formatted with the table name) - // so the delete carries no dynamically built SQL. - deleteSQL := deleteLiveRouteV6SQL - if isV4 { - deleteSQL = deleteLiveRouteV4SQL - } - - res, err := tx.Exec(deleteSQL, prefix, peerIP) - if err != nil { - return err - } - - affected, err := res.RowsAffected() - if err != nil { - return err - } - - if affected == 0 { - return nil - } - - // The prefix leaves the distribution when no live route has it any more. - lookupSQL := prefixHasLiveRouteV6SQL - if isV4 { - lookupSQL = prefixHasLiveRouteV4SQL - } - - var prefixHasRoute bool - - err = tx.QueryRow(lookupSQL, prefix).Scan(&prefixHasRoute) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - if isV4 { - d.counts.addRoutes(-int(affected), 0) - } else { - d.counts.addRoutes(0, -int(affected)) - } - - if !prefixHasRoute { - maskLength, _ := ipnet.Mask.Size() - if isV4 { - d.counts.addToDistribution([]int{maskLength}, nil, -1) - } else { - d.counts.addToDistribution(nil, []int{maskLength}, -1) - } - } - - return nil -} - -// GetPrefixDistribution returns the distribution of unique prefixes by mask -// length -func (d *Database) GetPrefixDistribution() ( - []PrefixDistribution, []PrefixDistribution, error, -) { - return d.GetPrefixDistributionContext(context.Background()) -} - -// GetPrefixDistributionContext returns the distribution of unique prefixes by -// mask length with context support. It reads every live route, so the stats -// read does not call it; it seeds the in-memory distribution once at startup. -func (d *Database) GetPrefixDistributionContext(ctx context.Context) ( - []PrefixDistribution, []PrefixDistribution, error, -) { - var ipv4, ipv6 []PrefixDistribution - - // IPv4 distribution - count unique prefixes from v4 table - query := ` - SELECT mask_length, COUNT(DISTINCT prefix) as count - FROM live_routes_v4 - GROUP BY mask_length - ORDER BY mask_length - ` - - rows4, err := d.db.QueryContext(ctx, query) - if err != nil { - return nil, nil, fmt.Errorf("failed to query IPv4 distribution: %w", err) - } - defer func() { - if rows4 != nil { - _ = rows4.Close() - } - }() - - for rows4.Next() { - var dist PrefixDistribution - - err := rows4.Scan(&dist.MaskLength, &dist.Count) - if err != nil { - return nil, nil, fmt.Errorf("failed to scan IPv4 distribution: %w", - err) - } - - ipv4 = append(ipv4, dist) - } - - // A read that stops partway ends the loop without an error of its own. - err = rows4.Err() - if err != nil { - return nil, nil, fmt.Errorf("failed to read IPv4 distribution: %w", err) - } - - // IPv6 distribution - count unique prefixes from v6 table - query = ` - SELECT mask_length, COUNT(DISTINCT prefix) as count - FROM live_routes_v6 - GROUP BY mask_length - ORDER BY mask_length - ` - - rows6, err := d.db.QueryContext(ctx, query) - if err != nil { - return nil, nil, fmt.Errorf("failed to query IPv6 distribution: %w", err) - } - defer func() { - if rows6 != nil { - _ = rows6.Close() - } - }() - - for rows6.Next() { - var dist PrefixDistribution - - err := rows6.Scan(&dist.MaskLength, &dist.Count) - if err != nil { - return nil, nil, fmt.Errorf("failed to scan IPv6 distribution: %w", - err) - } - - ipv6 = append(ipv6, dist) - } - - err = rows6.Err() - if err != nil { - return nil, nil, fmt.Errorf("failed to read IPv6 distribution: %w", err) - } - - return ipv4, ipv6, nil -} - -// GetLiveRouteCounts returns the count of IPv4 and IPv6 routes -func (d *Database) GetLiveRouteCounts() (int, int, error) { - return d.GetLiveRouteCountsContext(context.Background()) -} - -// GetLiveRouteCountsContext returns the count of IPv4 and IPv6 routes with -// context support -func (d *Database) GetLiveRouteCountsContext( - ctx context.Context, -) (int, int, error) { - var ipv4Count, ipv6Count int - - // Get IPv4 count from dedicated table - err := d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes_v4"). - Scan(&ipv4Count) - if err != nil { - return 0, 0, fmt.Errorf("failed to count IPv4 routes: %w", err) - } - - // Get IPv6 count from dedicated table - err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes_v6"). - Scan(&ipv6Count) - if err != nil { - return 0, 0, fmt.Errorf("failed to count IPv6 routes: %w", err) - } - - return ipv4Count, ipv6Count, nil -} - -// GetASDetails returns detailed information about an AS including prefixes -func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) { - return d.GetASDetailsContext(context.Background(), asn) -} - -// GetASDetailsContext returns the AS and the prefixes it originates, the IPv4 -// ones first, each once with the time of its newest route. It makes two index -// searches per prefix, however many peers carry the prefix. -// -// The routes of an AS can be stored before its record in the asns table. When -// the AS has no record, its handle and description come from the AS info -// lookup, and it has no seen times. An AS with no record, no lookup entry and -// no prefix is ErrNoRoute. -// -// ristypes.NoOrigin means no known origin, not an AS, so it is ErrNoRoute -// whatever is stored. -func (d *Database) GetASDetailsContext( - ctx context.Context, asn int, -) (*ASN, []LiveRoute, error) { - if asn == ristypes.NoOrigin { - return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) - } - - asnInfo, known, err := d.GetAS(ctx, asn) - if err != nil { - return nil, nil, err - } - - allPrefixes, err := d.originPrefixes(ctx, asn, ipVersionV4) - if err != nil { - return asnInfo, nil, err - } - - prefixesV6, err := d.originPrefixes(ctx, asn, ipVersionV6) - if err != nil { - return asnInfo, allPrefixes, err - } - - allPrefixes = append(allPrefixes, prefixesV6...) - - if !known && len(allPrefixes) == 0 { - return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) - } - - return asnInfo, allPrefixes, nil -} - // GetAS returns the AS's handle, description and first and last seen times // from the asns table. When the table has no record of the AS, its handle and // description come from the AS info lookup, and it has no seen times. The bool @@ -1977,144 +1124,6 @@ func (d *Database) GetASPeersContext( return peers, nil } -// GetPrefixDetails returns detailed information about a prefix -func (d *Database) GetPrefixDetails( - prefix string, -) ([]LiveRoute, map[int]ASN, error) { - return d.GetPrefixDetailsContext(context.Background(), prefix) -} - -// GetPrefixDetailsContext returns the live routes for a prefix and, keyed by -// AS number, the handle and description of each AS that originates it. -func (d *Database) GetPrefixDetailsContext( - ctx context.Context, prefix string, -) ([]LiveRoute, map[int]ASN, error) { - // Determine if it's IPv4 or IPv6 by parsing the prefix - _, ipnet, err := net.ParseCIDR(prefix) - if err != nil { - return nil, nil, fmt.Errorf("invalid prefix format: %w", err) - } - - tableName := tableLiveRoutesV4 - ipVersion := ipVersionV4 - - if ipnet.IP.To4() == nil { - tableName = tableLiveRoutesV6 - ipVersion = ipVersionV6 - } - - query := fmt.Sprintf(` - SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, - lr.last_updated, a.handle, a.description - FROM %s lr - LEFT JOIN asns a ON a.asn = lr.origin_asn - WHERE lr.prefix = ? - ORDER BY lr.origin_asn, lr.peer_ip - `, tableName) - - rows, err := d.db.QueryContext(ctx, query, prefix) - if err != nil { - return nil, nil, fmt.Errorf("failed to query prefix details: %w", err) - } - defer func() { _ = rows.Close() }() - - var routes []LiveRoute - - origins := make(map[int]ASN) - - for rows.Next() { - var ( - route LiveRoute - pathJSON string - handle, description sql.NullString - ) - - err := rows.Scan( - &route.OriginASN, &route.PeerIP, &pathJSON, &route.NextHop, - &route.LastUpdated, &handle, &description, - ) - if err != nil { - continue - } - - // Decode AS path - err = json.Unmarshal([]byte(pathJSON), &route.ASPath) - if err != nil { - route.ASPath = []int{} - } - - route.Prefix = prefix - route.IPVersion = ipVersion - route.MaskLength, _ = ipnet.Mask.Size() - routes = append(routes, route) - origins[route.OriginASN] = ASN{ - ASN: route.OriginASN, - Handle: handle.String, - Description: description.String, - } - } - - err = rows.Err() - if err != nil { - return nil, nil, fmt.Errorf("failed to read prefix details: %w", err) - } - - if len(routes) == 0 { - return nil, nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) - } - - return routes, origins, nil -} - -// GetRandomPrefixesByLength returns a random sample of prefixes with the -// specified mask length -func (d *Database) GetRandomPrefixesByLength( - maskLength, ipVersion, limit int, -) ([]LiveRoute, error) { - return d.GetRandomPrefixesByLengthContext(context.Background(), - maskLength, ipVersion, limit) -} - -// GetRandomPrefixesByLengthContext returns limit prefixes with the given mask -// length picked at random, or all of them when there are fewer, each once with -// its most recently updated live route, for a limit up to -// maxPrefixesToShuffle. However many routes there are, it makes at most -// maxPrefixesToShuffle+1 index searches to list prefixes, at most -// sampleLookupsPerPrefix random lookups for each prefix asked for, and one -// index search for each listed prefix's newest route. -func (d *Database) GetRandomPrefixesByLengthContext( - ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) { - tableName := tableLiveRoutesV4 - if ipVersion == ipVersionV6 { - tableName = tableLiveRoutesV6 - } - - // Reading one prefix more than maxPrefixesToShuffle tells whether these - // are all the prefixes with this mask length. - prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, - maxPrefixesToShuffle+1) - if err != nil { - return nil, err - } - - //nolint:gosec // The sample needs variety, not unpredictability. - rand.Shuffle(len(prefixes), func(i, j int) { - prefixes[i], prefixes[j] = prefixes[j], prefixes[i] - }) - - if len(prefixes) > maxPrefixesToShuffle { - prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, - limit, prefixes) - if err != nil { - return nil, err - } - } - - prefixes = prefixes[:min(limit, len(prefixes))] - - return d.newestRouteOfEachPrefix(ctx, tableName, ipVersion, prefixes) -} - // GetNextStaleASN returns a random ASN that needs WHOIS data refresh. func (d *Database) GetNextStaleASN( ctx context.Context, staleThreshold time.Duration, @@ -2237,136 +1246,6 @@ func (d *Database) UpdateASNWHOIS( return nil } -// GetIPInfo returns comprehensive IP information for the /ip endpoint. -func (d *Database) GetIPInfo(ip string) (*IPInfo, error) { - return d.GetIPInfoContext(context.Background(), ip) -} - -// ipInfoQuery reads the live route for one prefix with its peer count, the -// prefix's first_seen and the origin AS's details. %[1]s is the live route -// table and %[2]s the prefix table of the address's IP version. -const ipInfoQuery = ` - SELECT - lr.prefix, - lr.mask_length, - lr.origin_asn, - lr.last_updated, - (SELECT COUNT(DISTINCT peer_ip) FROM %[1]s - WHERE prefix = lr.prefix) as num_peers, - p.first_seen, - a.handle, - a.description, - a.as_name, - a.org_name, - a.org_id, - a.address, - a.country_code, - a.abuse_email, - a.rir, - a.whois_updated_at - FROM %[1]s lr - LEFT JOIN %[2]s p ON p.prefix = lr.prefix - LEFT JOIN asns a ON a.asn = lr.origin_asn - WHERE lr.prefix = ? - LIMIT 1 - ` - -// GetIPInfoContext returns comprehensive IP information with context support. -// -// It finds the most specific live route for the address with point lookups on -// the prefix index: for each mask length from the longest (32 or 128) down to -// minLookupMaskLengthV4 or minLookupMaskLengthV6 it looks up the address's -// network prefix at that length, and the first length with a live route wins. -// That is at most 25 or 113 indexed lookups, however many routes are live. The -// prefix is looked up in the text form net/netip prints, which is the form the -// prefix handler stores. -func (d *Database) GetIPInfoContext( - ctx context.Context, ip string, -) (*IPInfo, error) { - addr, err := netip.ParseAddr(ip) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) - } - // An IPv4 address written in IPv6 form (::ffff:192.0.2.1) is an IPv4 lookup. - addr = addr.Unmap() - - info := &IPInfo{IP: ip, IPVersion: ipVersionV6} - minMaskLength := minLookupMaskLengthV6 - - routesTable, prefixesTable := tableLiveRoutesV6, "prefixes_v6" - if addr.Is4() { - routesTable, prefixesTable = tableLiveRoutesV4, "prefixes_v4" - minMaskLength = minLookupMaskLengthV4 - info.IPVersion = ipVersionV4 - } - - // Route info with peer count and prefix first_seen - query := fmt.Sprintf(ipInfoQuery, routesTable, prefixesTable) - - stmt, err := d.db.PrepareContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to prepare route query: %w", err) - } - defer func() { _ = stmt.Close() }() - - var ( - handle, description, asName, orgName, orgID sql.NullString - address, countryCode, abuseEmail, rir sql.NullString - prefixFirstSeen, whoisUpdatedAt sql.NullTime - ) - - found := false - - for bits := addr.BitLen(); bits >= minMaskLength; bits-- { - prefix := netip.PrefixFrom(addr, bits).Masked().String() - - err := stmt.QueryRowContext(ctx, prefix).Scan( - &info.Netblock, &info.MaskLength, &info.ASN, &info.LastSeen, - &info.NumPeers, &prefixFirstSeen, &handle, &description, - &asName, &orgName, &orgID, &address, &countryCode, &abuseEmail, - &rir, &whoisUpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { - continue - } - - if err != nil { - return nil, fmt.Errorf("failed to query routes: %w", err) - } - - found = true - - break - } - - if !found { - return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) - } - - info.Handle = handle.String - info.Description = description.String - info.ASName = asName.String - info.OrgName = orgName.String - info.OrgID = orgID.String - info.Address = address.String - info.CountryCode = countryCode.String - info.AbuseEmail = abuseEmail.String - info.RIR = rir.String - - if prefixFirstSeen.Valid { - info.FirstSeen = prefixFirstSeen.Time - } - - // Check if WHOIS data needs refresh (never fetched or older than 30 days). - // A route with origin 0 has no known origin, so no AS to look up. - const staleThreshold = 30 * 24 * time.Hour - - info.NeedsWHOISRefresh = info.ASN != 0 && (!whoisUpdatedAt.Valid || - time.Since(whoisUpdatedAt.Time) > staleThreshold) - - return info, nil -} - // Vacuum runs incremental vacuum to return up to 1000 unused pages to the // filesystem. Like Analyze and Checkpoint, it holds the database lock, so it // and the batch writes wait for each other instead of failing on SQLite's @@ -2598,321 +1477,3 @@ func (d *Database) rollback(tx *loggingTx) { d.logger.Error("Failed to rollback transaction", "error", err) } } - -// deletePeerRouteChunk deletes, in one transaction, the first -// peerRouteChunkSize IPv4 and the first peerRouteChunkSize IPv6 live routes of -// the peer, or as many as it has. It returns how many of each it deleted. -func (d *Database) deletePeerRouteChunk(peerIP string) (int, int, error) { - d.lock("DeleteLiveRoutesOfPeer") - defer d.unlock() - - tx, err := d.beginTx() - if err != nil { - return 0, 0, fmt.Errorf("failed to begin transaction: %w", err) - } - defer d.rollback(tx) - - ctx := context.Background() - - deletedV4, goneMaskLengthsV4, err := deletePeerRoutes(ctx, tx, - peerRouteChunkV4SQL, deletePeerRouteChunkV4SQL, peerIP) - if err != nil { - return 0, 0, fmt.Errorf("failed to delete the IPv4 routes of peer %s: %w", - peerIP, err) - } - - deletedV6, goneMaskLengthsV6, err := deletePeerRoutes(ctx, tx, - peerRouteChunkV6SQL, deletePeerRouteChunkV6SQL, peerIP) - if err != nil { - return 0, 0, fmt.Errorf("failed to delete the IPv6 routes of peer %s: %w", - peerIP, err) - } - - err = tx.Commit() - if err != nil { - return 0, 0, fmt.Errorf("failed to commit transaction: %w", err) - } - - d.counts.addRoutes(-int(deletedV4), -int(deletedV6)) - d.counts.addToDistribution(goneMaskLengthsV4, goneMaskLengthsV6, -1) - - return int(deletedV4), int(deletedV6), nil -} - -// The AS page lists the prefixes an AS originates one at a time, each in two -// index searches that read no table row: one of the origin_asn and prefix -// index for the AS's first prefix after the previous one, and one of the -// prefix and last_updated index for the time of its newest route. Neither -// reads more for a prefix that more peers carry. -const ( - nextOriginPrefixV4SQL = `SELECT prefix FROM live_routes_v4 - WHERE origin_asn = ? AND prefix > ? ORDER BY prefix LIMIT 1` - newestRouteTimeV4SQL = `SELECT last_updated FROM live_routes_v4 - WHERE prefix = ? ORDER BY last_updated DESC LIMIT 1` - - nextOriginPrefixV6SQL = `SELECT prefix FROM live_routes_v6 - WHERE origin_asn = ? AND prefix > ? ORDER BY prefix LIMIT 1` - newestRouteTimeV6SQL = `SELECT last_updated FROM live_routes_v6 - WHERE prefix = ? ORDER BY last_updated DESC LIMIT 1` -) - -// originPrefixes returns every prefix asn originates in the live route table -// of ipVersion, in text order, each with its mask length and the time of the -// prefix's newest route, whichever AS that route names as origin. A prefix -// whose routes were all withdrawn between the two searches is left out. -func (d *Database) originPrefixes( - ctx context.Context, asn, ipVersion int, -) ([]LiveRoute, error) { - family, nextSQL, newestSQL := ipv4Family, nextOriginPrefixV4SQL, - newestRouteTimeV4SQL - if ipVersion == ipVersionV6 { - family, nextSQL, newestSQL = ipv6Family, nextOriginPrefixV6SQL, - newestRouteTimeV6SQL - } - - nextStmt, err := d.db.PrepareContext(ctx, nextSQL) - if err != nil { - return nil, fmt.Errorf("failed to prepare %s prefix query: %w", - family, err) - } - defer func() { _ = nextStmt.Close() }() - - newestStmt, err := d.db.PrepareContext(ctx, newestSQL) - if err != nil { - return nil, fmt.Errorf("failed to prepare %s route time query: %w", - family, err) - } - defer func() { _ = newestStmt.Close() }() - - var routes []LiveRoute - - previous := "" // every prefix sorts after the empty string - - for { - var prefix string - - err := nextStmt.QueryRowContext(ctx, asn, previous).Scan(&prefix) - if errors.Is(err, sql.ErrNoRows) { - return routes, nil - } - - if err != nil { - return nil, fmt.Errorf("failed to list %s prefixes: %w", family, err) - } - - previous = prefix - route := LiveRoute{Prefix: prefix, IPVersion: ipVersion, OriginASN: asn} - - err = newestStmt.QueryRowContext(ctx, prefix).Scan(&route.LastUpdated) - if errors.Is(err, sql.ErrNoRows) { - continue - } - - if err != nil { - return nil, fmt.Errorf("failed to query route time of %s: %w", - prefix, err) - } - - route.MaskLength, err = prefixMaskLength(prefix) - if err != nil { - return nil, err - } - - routes = append(routes, route) - } -} - -// firstPrefixesByLength returns the first n prefixes, in text order, with the -// given mask length. Each prefix is one search of the mask length and prefix -// index for the prefix after the previous one, so the read does not grow with -// the number of peers that carry a prefix, as a SELECT DISTINCT would. -func (d *Database) firstPrefixesByLength( - ctx context.Context, tableName string, maskLength, n int) ([]string, error) { - //nolint:gosec // Table name is hardcoded based on IP version - query := fmt.Sprintf(` - SELECT prefix FROM %s - WHERE mask_length = ? AND prefix > ? - ORDER BY prefix - LIMIT 1 - `, tableName) - - stmt, err := d.db.PrepareContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to prepare prefix query: %w", err) - } - defer func() { _ = stmt.Close() }() - - var prefixes []string - - previous := "" // every prefix sorts after the empty string - - for len(prefixes) < n { - var prefix string - - err := stmt.QueryRowContext(ctx, maskLength, previous).Scan(&prefix) - if errors.Is(err, sql.ErrNoRows) { - break - } - - if err != nil { - return nil, fmt.Errorf("failed to list prefixes: %w", err) - } - - prefixes = append(prefixes, prefix) - previous = prefix - } - - return prefixes, nil -} - -// samplePrefixesByLength picks limit distinct prefixes with the given mask -// length at random, in at most sampleLookupsPerPrefix lookups for each prefix -// asked for. ORDER BY RANDOM() would read every route with that mask length. -// Instead each lookup draws a row id between those of the first and last route -// with that mask length and takes the prefix of the first such route at or -// after it. SQLite keeps the row id at the end of every index entry, so the -// mask length index is ordered by mask length, then row id, and the lookup is -// one search of it. -// -// The sample is uneven, as the page needs a varied sample, not an even one: a -// route is picked in proportion to the row ids between it and the previous -// route with that mask length, including the row ids of routes with other mask -// lengths and of withdrawn routes. A route that follows a long run of -// withdrawn routes is picked far more often than one that directly follows -// another route with that mask length. -// -// Should the lookups keep landing on prefixes already picked, the rest are -// taken from shuffled, prefixes of that length in random order. -func (d *Database) samplePrefixesByLength( - ctx context.Context, tableName string, maskLength, limit int, - shuffled []string, -) ([]string, error) { - //nolint:gosec // Table name is hardcoded based on IP version - rangeQuery := fmt.Sprintf(` - SELECT - (SELECT MIN(rowid) FROM %[1]s WHERE mask_length = ?), - (SELECT MAX(rowid) FROM %[1]s WHERE mask_length = ?) - `, tableName) - - var first, last sql.NullInt64 - - err := d.db.QueryRowContext(ctx, rangeQuery, maskLength, maskLength). - Scan(&first, &last) - if err != nil { - return nil, fmt.Errorf("failed to read row id range: %w", err) - } - - if !first.Valid { - // Every route with this mask length was withdrawn since they were listed. - return nil, nil - } - - //nolint:gosec // Table name is hardcoded based on IP version - lookupQuery := fmt.Sprintf(` - SELECT prefix FROM %s - WHERE mask_length = ? AND rowid >= ? - ORDER BY rowid - LIMIT 1 - `, tableName) - - stmt, err := d.db.PrepareContext(ctx, lookupQuery) - if err != nil { - return nil, fmt.Errorf("failed to prepare prefix lookup: %w", err) - } - defer func() { _ = stmt.Close() }() - - picked := make(map[string]bool, limit) - prefixes := make([]string, 0, limit) - pick := func(prefix string) { - if !picked[prefix] { - picked[prefix] = true - prefixes = append(prefixes, prefix) - } - } - - maxLookups := limit * sampleLookupsPerPrefix - for lookups := 0; lookups < maxLookups && len(prefixes) < limit; lookups++ { - //nolint:gosec // The sample needs variety, not unpredictability. - rowID := first.Int64 + rand.Int64N(last.Int64-first.Int64+1) - - var prefix string - - err := stmt.QueryRowContext(ctx, maskLength, rowID).Scan(&prefix) - if errors.Is(err, sql.ErrNoRows) { - // The routes from rowID on were withdrawn since the range was read. - continue - } - - if err != nil { - return nil, fmt.Errorf("failed to look up prefix: %w", err) - } - - pick(prefix) - } - - for _, prefix := range shuffled { - if len(prefixes) == limit { - break - } - - pick(prefix) - } - - return prefixes, nil -} - -// newestRouteOfEachPrefix returns the most recently updated live route of each -// prefix, one search of the prefix and last_updated index per prefix however -// many peers carry it. A prefix whose routes were all withdrawn since it was -// picked is left out. -func (d *Database) newestRouteOfEachPrefix( - ctx context.Context, tableName string, ipVersion int, prefixes []string, -) ([]LiveRoute, error) { - //nolint:gosec // Table name is hardcoded based on IP version - query := fmt.Sprintf(` - SELECT prefix, mask_length, origin_asn, as_path, peer_ip, last_updated - FROM %s - WHERE prefix = ? - ORDER BY last_updated DESC - LIMIT 1 - `, tableName) - - stmt, err := d.db.PrepareContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to prepare route query: %w", err) - } - defer func() { _ = stmt.Close() }() - - routes := make([]LiveRoute, 0, len(prefixes)) - - for _, prefix := range prefixes { - route := LiveRoute{IPVersion: ipVersion} - - var pathJSON string - - err := stmt.QueryRowContext(ctx, prefix).Scan( - &route.Prefix, - &route.MaskLength, - &route.OriginASN, - &pathJSON, - &route.PeerIP, - &route.LastUpdated, - ) - if errors.Is(err, sql.ErrNoRows) { - continue - } - - if err != nil { - return nil, fmt.Errorf("failed to query route of %s: %w", prefix, err) - } - - err = json.Unmarshal([]byte(pathJSON), &route.ASPath) - if err != nil { - route.ASPath = []int{} - } - - routes = append(routes, route) - } - - return routes, nil -} diff --git a/internal/database/database_internal_test.go b/internal/database/database_internal_test.go index 6891927..3195ead 100644 --- a/internal/database/database_internal_test.go +++ b/internal/database/database_internal_test.go @@ -8,12 +8,8 @@ import ( "errors" "fmt" "log/slog" - "math/rand/v2" "net/netip" "os" - "slices" - "strconv" - "strings" "sync" "testing" "time" @@ -21,8 +17,8 @@ import ( "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/ristypes" + "git.eeqj.de/sneak/routewatch/internal/routetable" "git.eeqj.de/sneak/routewatch/pkg/asinfo" - "github.com/mattn/go-sqlite3" ) // tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in @@ -32,8 +28,8 @@ const tempStoreMemory = 2 // autoVacuumIncremental is the PRAGMA auto_vacuum value meaning "incremental". const autoVacuumIncremental = 2 -// vacuumTestRoutes is how many routes the vacuum test writes and then deletes, -// enough to leave many free pages in the file. +// vacuumTestRoutes is how many rows of the routes table the vacuum test writes +// and then deletes, enough to leave many free pages in the file. const vacuumTestRoutes = 2000 // heldConnections is how many pooled connections the pragma test holds open at @@ -43,7 +39,7 @@ const heldConnections = 5 // bytesPerKiB turns a cache_size in KiB into bytes. const bytesPerKiB = 1024 -// testPeerIP is the peer every route in the IP lookup test is learned from. +// testPeerIP is the address of the BGP peer the peer write tests store. const testPeerIP = "192.0.2.254" // Parameters for the checkpoint-contention regression test. @@ -73,6 +69,9 @@ const ( lockWaitTimeout = time.Millisecond // lockTestASN is the AS the batch write in the lock tests stores. lockTestASN = 64500 + // lockTestPrefix is the prefix the batch writes in the checkpoint tests + // store. + lockTestPrefix = "192.0.2.0/24" // readerCheckpointLimit is how long a checkpoint, and a batch write // waiting for it, may take while a reader is open: well under the busy // timeout, which a checkpoint that waits for the reader takes. @@ -84,275 +83,6 @@ const ( time.Second ) -// Parameters for the prefix-length sample tests. -const ( - // sampleLimit is how many prefixes the tests ask the sample for. - sampleLimit = 10 - // sampledPrefixCount is how many prefixes the sampling test stores, more - // than sampleLimit so the sample has to choose among them. - sampledPrefixCount = 30 - // secondV4PeerIP and secondV6PeerIP are the second peer each prefix is - // learned from, besides the one mkV4Route and mkV6Route set. - secondV4PeerIP = "192.0.2.2" - secondV6PeerIP = "2001:db8::2" - // largeSamplePrefixCount is how many prefixes the test of a mask length - // with more than maxPrefixesToShuffle prefixes stores: twice that, so about - // half of them come after the first maxPrefixesToShuffle+1 in text order. - largeSamplePrefixCount = 2 * maxPrefixesToShuffle - // largeSampleLimit is how many prefixes that test asks for. Each random - // lookup lands after the first maxPrefixesToShuffle+1 in text order about - // half the time, so none of them doing so is about as likely as - // largeSampleLimit coin tosses all coming up heads. - largeSampleLimit = 30 - // liveRoutesV4Table and liveRoutesV6Table are the live route tables the - // tests pass to the sampling steps. - liveRoutesV4Table = "live_routes_v4" - liveRoutesV6Table = "live_routes_v6" -) - -// TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both -// families and checks that a lookup returns the most specific one covering the -// address, ErrNoRoute when none covers it, and the next less specific prefix -// once the only route of the most specific one is withdrawn. -func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - // Nested live prefixes, each originated by its own AS. - origins := map[string]int{ - "10.0.0.0/8": 64500, - "10.1.0.0/16": 64501, - "10.1.2.0/24": 64502, - "2001:db8::/32": 64500, - "2001:db8:1::/48": 64501, - "2001:db8:1:2::/64": 64502, - } - writeOriginRoutes(t, db, origins) - - // lookup checks that ip resolves to the live prefix want, or to ErrNoRoute - // when want is empty. - lookup := func(ip, want string) { - t.Helper() - - info, err := db.GetIPInfo(ip) - if want == "" { - if !errors.Is(err, ErrNoRoute) { - t.Errorf("GetIPInfo(%s) = %+v, %v; want ErrNoRoute", ip, info, err) - } - - return - } - - if err != nil { - t.Errorf("GetIPInfo(%s): %v", ip, err) - - return - } - - if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() || - info.ASN != origins[want] { - t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d", - ip, info.Netblock, info.MaskLength, info.ASN, want, origins[want]) - } - } - - lookup("10.1.2.3", "10.1.2.0/24") - lookup("::ffff:10.1.2.3", "10.1.2.0/24") - lookup("10.1.3.4", "10.1.0.0/16") - lookup("10.2.0.1", "10.0.0.0/8") - lookup("192.0.2.1", "") - lookup("2001:db8:1:2::3", "2001:db8:1:2::/64") - lookup("2001:db8:1:3::4", "2001:db8:1::/48") - lookup("2001:db8:2::1", "2001:db8::/32") - lookup("2001:db9::1", "") - - err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{ - {Prefix: "10.1.2.0/24", PeerIP: testPeerIP, IPVersion: ipVersionV4}, - {Prefix: "2001:db8:1:2::/64", PeerIP: testPeerIP, IPVersion: ipVersionV6}, - }) - if err != nil { - t.Fatalf("DeleteLiveRouteBatch: %v", err) - } - - lookup("10.1.2.3", "10.1.0.0/16") - lookup("2001:db8:1:2::3", "2001:db8:1::/48") -} - -// TestGetIPInfoIgnoresShortRoutes checks that the IP lookup does not report a -// default route, or a route shorter than /8 for IPv4 or /16 for IPv6, as the -// route for an address, and that a route of exactly /8 or /16, or longer, still -// is. The prefixes are written out so that a wrong limit fails the test. -func TestGetIPInfoIgnoresShortRoutes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - // announce writes one live route for prefix, originated by asn. - announce := func(prefix string, asn int) { - t.Helper() - - writeOriginRoutes(t, db, map[string]int{prefix: asn}) - } - - // lookup checks that ip resolves to the live prefix netblock from asn. - lookup := func(ip, netblock string, asn int) { - t.Helper() - - info, err := db.GetIPInfo(ip) - if err != nil { - t.Errorf("GetIPInfo(%s): %v", ip, err) - - return - } - - if info.Netblock != netblock || info.ASN != asn { - t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d", - ip, info.Netblock, info.ASN, netblock, asn) - } - } - - // Default and very short routes, and routes one bit shorter than each limit. - announce("2000::/3", 64500) - announce("0.0.0.0/0", 64500) - announce("2000::/15", 64500) - announce("192.0.0.0/7", 64500) - - for _, ip := range []string{"2001:db8::1", "192.0.2.1"} { - info, err := db.GetIPInfo(ip) - if !errors.Is(err, ErrNoRoute) { - t.Errorf("GetIPInfo(%s) with only short routes = %+v, %v; "+ - "want ErrNoRoute", ip, info, err) - } - } - - // Routes of exactly the minimum length are used. - announce("2001::/16", 64501) - announce("192.0.0.0/8", 64501) - - lookup("2001:db8::1", "2001::/16", 64501) - lookup("192.0.2.1", "192.0.0.0/8", 64501) - - announce("2001:db8::/32", 64502) - - lookup("2001:db8::1", "2001:db8::/32", 64502) -} - -// TestGetIPInfoNeedsNoWHOISRefreshForOriginZero checks that the IP lookup asks -// for no WHOIS lookup when the address's route has origin 0, no known origin, -// and still asks for one when its origin AS has no WHOIS data. -func TestGetIPInfoNeedsNoWHOISRefreshForOriginZero(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - writeOriginRoutes(t, db, map[string]int{"10.0.0.0/8": 0, "11.0.0.0/8": 64500}) - - for ip, want := range map[string]bool{"10.0.0.1": false, "11.0.0.1": true} { - info, err := db.GetIPInfo(ip) - if err != nil { - t.Fatalf("GetIPInfo(%s): %v", ip, err) - } - - if info.NeedsWHOISRefresh != want { - t.Errorf("GetIPInfo(%s) asks for a WHOIS lookup of AS%d: %t, want %t", - ip, info.ASN, info.NeedsWHOISRefresh, want) - } - } -} - -// TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer checks that each live route -// table refuses a second route from a peer for a prefix, even with another -// origin AS. An upsert updates the peer's route before it would insert one, so -// only an insert straight into the table shows what the table allows. -func TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - const otherOriginASN = 64501 - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - for _, tt := range []struct { - route *LiveRoute - insertSQL string - }{ - { - route: mkV4Route(t, "198.51.100.0/24", 64500, ts), - insertSQL: insertLiveRouteV4SQL, - }, - { - route: mkV6Route(testV6Prefix, 64500, ts), - insertSQL: insertLiveRouteV6SQL, - }, - } { - err := db.UpsertLiveRoute(tt.route) - if err != nil { - t.Fatalf("UpsertLiveRoute(%s): %v", tt.route.Prefix, err) - } - - _, err = db.db.ExecContext(t.Context(), tt.insertSQL, - tt.route.Prefix, tt.route.MaskLength, otherOriginASN, - tt.route.PeerIP, "[]", tt.route.NextHop, ts) - - var sqliteErr sqlite3.Error - if !errors.As(err, &sqliteErr) || - sqliteErr.ExtendedCode != sqlite3.ErrConstraintUnique { - t.Errorf("second route from peer %s for %s: error %v, "+ - "want a UNIQUE constraint error", - tt.route.PeerIP, tt.route.Prefix, err) - } - } -} - -// writeOriginRoutes writes one live route from testPeerIP for each prefix in -// origins, originated by the AS it maps to. -func writeOriginRoutes(t *testing.T, db *Database, origins map[string]int) { - t.Helper() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - routes := make([]*LiveRoute, 0, len(origins)) - - for prefix, asn := range origins { - routes = append(routes, &LiveRoute{ - Prefix: prefix, - MaskLength: netip.MustParsePrefix(prefix).Bits(), - IPVersion: detectIPVersion(prefix), - OriginASN: asn, - PeerIP: testPeerIP, - ASPath: []int{asn}, - NextHop: testPeerIP, - LastUpdated: ts, - }) - } - - err := db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } -} - // TestConnectionPoolPragmas holds several pooled connections open at once and // checks each one carries the per-connection settings from the DSN, plus the // process-wide heap limits, and sees the new file with auto_vacuum @@ -515,12 +245,13 @@ func readPragma( return value } -// TestVacuumReturnsFreePages checks that after routes are deleted from a new -// database, one Vacuum call returns every page they used. The deletes leave -// fewer free pages than the 1000 Vacuum frees per call, so none may remain. -// With auto_vacuum off (issue https://git.eeqj.de/sneak/routewatch/issues/43) -// the free pages stayed in the file, and with the PRAGMA run by ExecContext -// Vacuum freed only one page per call. +// TestVacuumReturnsFreePages checks that after the rows of the routes table +// are deleted from a new database, one Vacuum call returns every page they +// used. The deletes leave fewer free pages than the 1000 Vacuum frees per +// call, so none may remain. With auto_vacuum off (issue +// https://git.eeqj.de/sneak/routewatch/issues/43) the free pages stayed in the +// file, and with the PRAGMA run by ExecContext Vacuum freed only one page per +// call. func TestVacuumReturnsFreePages(t *testing.T) { t.Parallel() @@ -533,32 +264,17 @@ func TestVacuumReturnsFreePages(t *testing.T) { defer func() { _ = db.Close() }() ctx := context.Background() - ts := time.Now().UTC() - - const asn = 64500 - - routes := make([]*LiveRoute, 0, vacuumTestRoutes) - deletions := make([]LiveRouteDeletion, 0, vacuumTestRoutes) + table := routetable.New(routetable.Params{}) for i := range vacuumTestRoutes { - route := mkV4Route(t, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asn, ts) - routes = append(routes, route) - deletions = append(deletions, LiveRouteDeletion{ - Prefix: route.Prefix, - PeerIP: route.PeerIP, - IPVersion: ipVersionV4, - }) + announceTo(table, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asA, peerA, 0) } - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("failed to write routes: %v", err) - } + writeTableChanges(t, db, table) - err = db.DeleteLiveRouteBatch(deletions) - if err != nil { - t.Fatalf("failed to delete routes: %v", err) - } + // The peer going down removes every prefix, and so every row. + table.PeerDown(peerA) + writeTableChanges(t, db, table) var before int @@ -568,7 +284,7 @@ func TestVacuumReturnsFreePages(t *testing.T) { } if before == 0 { - t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes) + t.Fatalf("no free pages after deleting %d rows", vacuumTestRoutes) } err = db.Vacuum(ctx) @@ -844,12 +560,12 @@ func TestCheckpointLockWaitEndsWithContext(t *testing.T) { db.mu.Unlock() } -// TestCheckpointDoesNotWaitForReader keeps a read transaction on -// live_routes_v4 open while a route is committed, then starts Checkpoint and a -// batch write together. It checks that both end within readerCheckpointLimit, -// whichever of them takes the database lock first, and that Checkpoint reports -// the part of the WAL the reader still needs as left uncopied. A TRUNCATE -// checkpoint waited the busy timeout for the reader while holding the lock +// TestCheckpointDoesNotWaitForReader keeps a read transaction on prefixes_v4 +// open while a prefix is committed, then starts Checkpoint and a batch write +// together. It checks that both end within readerCheckpointLimit, whichever of +// them takes the database lock first, and that Checkpoint reports the part of +// the WAL the reader still needs as left uncopied. A TRUNCATE checkpoint +// waited the busy timeout for the reader while holding the lock // (https://git.eeqj.de/sneak/routewatch/issues/102). func TestCheckpointDoesNotWaitForReader(t *testing.T) { t.Parallel() @@ -863,10 +579,9 @@ func TestCheckpointDoesNotWaitForReader(t *testing.T) { ctx := context.Background() ts := time.Now().UTC() - err = db.UpsertLiveRouteBatch( - []*LiveRoute{mkV4Route(t, "198.51.100.0/24", lockTestASN, ts)}) + err = db.UpdatePrefixesBatch(map[string]time.Time{"198.51.100.0/24": ts}) if err != nil { - t.Fatalf("failed to store the route the reader reads: %v", err) + t.Fatalf("failed to store the prefix the reader reads: %v", err) } reader, err := db.db.Conn(ctx) @@ -884,18 +599,17 @@ func TestCheckpointDoesNotWaitForReader(t *testing.T) { } defer func() { _, _ = reader.ExecContext(ctx, "ROLLBACK") }() - var routes int + var prefixes int err = reader.QueryRowContext(ctx, - "SELECT count(*) FROM live_routes_v4").Scan(&routes) + "SELECT count(*) FROM prefixes_v4").Scan(&prefixes) if err != nil { - t.Fatalf("failed to read the live routes: %v", err) + t.Fatalf("failed to read the prefixes: %v", err) } - err = db.UpsertLiveRouteBatch( - []*LiveRoute{mkV4Route(t, "203.0.113.0/24", lockTestASN, ts)}) + err = db.UpdatePrefixesBatch(map[string]time.Time{"203.0.113.0/24": ts}) if err != nil { - t.Fatalf("failed to store a route after the read: %v", err) + t.Fatalf("failed to store a prefix after the read: %v", err) } started := time.Now() @@ -903,12 +617,13 @@ func TestCheckpointDoesNotWaitForReader(t *testing.T) { go func() { checkpointed <- db.Checkpoint(ctx) }() - // A route write, not an AS write: the first new AS loads the embedded AS + // A prefix write, not an AS write: the first new AS loads the embedded AS // names, which takes seconds under -race. - route := mkV4Route(t, "192.0.2.0/24", lockTestASN, ts) written := make(chan error, 1) - go func() { written <- db.UpsertLiveRouteBatch([]*LiveRoute{route}) }() + go func() { + written <- db.UpdatePrefixesBatch(map[string]time.Time{lockTestPrefix: ts}) + }() err = <-checkpointed if !errors.Is(err, ErrCheckpointIncomplete) { @@ -966,12 +681,12 @@ func TestCheckpointEmptiesWALOnceNoReadIsOpen(t *testing.T) { t.Fatalf("failed to begin the read transaction: %v", err) } - var routes int + var prefixes int err = reader.QueryRowContext(ctx, - "SELECT count(*) FROM live_routes_v4").Scan(&routes) + "SELECT count(*) FROM prefixes_v4").Scan(&prefixes) if err != nil { - t.Fatalf("failed to read the live routes: %v", err) + t.Fatalf("failed to read the prefixes: %v", err) } fillWALPastLimit(ctx, t, db) @@ -990,9 +705,8 @@ func TestCheckpointEmptiesWALOnceNoReadIsOpen(t *testing.T) { took, truncateCheckpointLimit) } - err = db.UpsertLiveRouteBatch([]*LiveRoute{ - mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()), - }) + err = db.UpdatePrefixesBatch( + map[string]time.Time{lockTestPrefix: time.Now().UTC()}) if err != nil { t.Errorf("batch write after Checkpoint gave up: %v", err) } @@ -1056,7 +770,7 @@ func walFileSize(t *testing.T, db *Database) int64 { return wal.Size() } -// TestStartupCheckpointLogsItsFigures opens a new database, writes a route, +// TestStartupCheckpointLogsItsFigures opens a new database, writes a prefix, // and opens the same file again while the first stays open, so that its WAL // file stays. The first open finds no WAL file: it logs a size of 0 and no // warning. The second logs the size of the WAL file the first left, and the @@ -1083,16 +797,15 @@ func TestStartupCheckpointLogsItsFigures(t *testing.T) { "want 0", line["wal_bytes"]) } - err := db.UpsertLiveRouteBatch([]*LiveRoute{ - mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()), - }) + err := db.UpdatePrefixesBatch( + map[string]time.Time{lockTestPrefix: time.Now().UTC()}) if err != nil { - t.Fatalf("failed to store a route: %v", err) + t.Fatalf("failed to store a prefix: %v", err) } walBytes := walFileSize(t, db) if walBytes == 0 { - t.Fatal("WAL file empty after the route write, want frames in it") + t.Fatal("WAL file empty after the prefix write, want frames in it") } _, lines = openLogged(t, cfg) @@ -1111,7 +824,7 @@ func TestStartupCheckpointLogsItsFigures(t *testing.T) { } // TestStartupCheckpointWarnsWhenIncomplete keeps a read open on a database -// while a route is written after the read began, then opens the same file +// while a prefix is written after the read began, then opens the same file // again. The second open's start-up checkpoint cannot copy the frames written // after the read began, and gives up once the busy timeout has passed. It // checks that the checkpoint is logged as a warning with busy 1 and fewer @@ -1138,19 +851,18 @@ func TestStartupCheckpointWarnsWhenIncomplete(t *testing.T) { } defer func() { _, _ = reader.ExecContext(ctx, "ROLLBACK") }() - var routes int + var prefixes int err = reader.QueryRowContext(ctx, - "SELECT count(*) FROM live_routes_v4").Scan(&routes) + "SELECT count(*) FROM prefixes_v4").Scan(&prefixes) if err != nil { - t.Fatalf("failed to read the live routes: %v", err) + t.Fatalf("failed to read the prefixes: %v", err) } - err = db.UpsertLiveRouteBatch([]*LiveRoute{ - mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()), - }) + err = db.UpdatePrefixesBatch( + map[string]time.Time{lockTestPrefix: time.Now().UTC()}) if err != nil { - t.Fatalf("failed to store a route after the read: %v", err) + t.Fatalf("failed to store a prefix after the read: %v", err) } _, lines := openLogged(t, cfg) @@ -1226,560 +938,6 @@ func findLine( return nil } -// TestGetRandomPrefixesByLengthListsPrefixOnce stores one prefix of each -// family learned from two peers in the same second and checks that the -// prefix-length sample lists each of them once. -func TestGetRandomPrefixesByLengthListsPrefixOnce(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - v4First := mkV4Route(t, "198.51.100.0/24", 64500, ts) - v4Second := mkV4Route(t, "198.51.100.0/24", 64500, ts) - v4Second.PeerIP = secondV4PeerIP - v6First := mkV6Route(testV6Prefix, 64500, ts) - v6Second := mkV6Route(testV6Prefix, 64500, ts) - v6Second.PeerIP = secondV6PeerIP - - err = db.UpsertLiveRouteBatch( - []*LiveRoute{v4First, v4Second, v6First, v6Second}) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - for _, want := range []*LiveRoute{v4First, v6First} { - routes, err := db.GetRandomPrefixesByLength(want.MaskLength, - want.IPVersion, sampleLimit) - if err != nil { - t.Fatalf("GetRandomPrefixesByLength(%d, %d): %v", - want.MaskLength, want.IPVersion, err) - } - - if len(routes) != 1 || routes[0].Prefix != want.Prefix { - t.Errorf("GetRandomPrefixesByLength(%d, %d) = %+v, want %s listed once", - want.MaskLength, want.IPVersion, routes, want.Prefix) - } - } -} - -// TestGetRandomPrefixesByLengthListsNewestRoute stores a prefix learned from -// two peers at different times, the older route first, and checks that the -// prefix-length sample lists the newer route. -func TestGetRandomPrefixesByLengthListsNewestRoute(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - older := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - olderRoute := mkV4Route(t, "198.51.100.0/24", 64500, older) - newerRoute := mkV4Route(t, "198.51.100.0/24", 64500, older.Add(time.Minute)) - newerRoute.PeerIP = secondV4PeerIP - - err = db.UpsertLiveRouteBatch([]*LiveRoute{olderRoute, newerRoute}) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - routes, err := db.GetRandomPrefixesByLength(newerRoute.MaskLength, - ipVersionV4, sampleLimit) - if err != nil { - t.Fatalf("GetRandomPrefixesByLength: %v", err) - } - - if len(routes) != 1 || routes[0].PeerIP != newerRoute.PeerIP { - t.Errorf("GetRandomPrefixesByLength = %+v, want only the newer route, "+ - "from %s", routes, newerRoute.PeerIP) - } -} - -// TestGetRandomPrefixesByLengthSamplesManyPrefixes stores more /24 prefixes -// than the sample asks for, each followed by a /16 so that routes of another -// mask length sit between the /24 rows, and each prefix learned from two peers -// in the same second. It checks both the sample the pages get, picked from the -// shuffled prefixes, and the random lookups used for a mask length with more -// than maxPrefixesToShuffle prefixes. -func TestGetRandomPrefixesByLengthSamplesManyPrefixes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - var routes []*LiveRoute - - stored := make([]string, 0, sampledPrefixCount) - - for i := range sampledPrefixCount { - prefix := fmt.Sprintf("10.0.%d.0/24", i) - for _, p := range []string{prefix, fmt.Sprintf("172.%d.0.0/16", i)} { - first := mkV4Route(t, p, 64500, ts) - second := mkV4Route(t, p, 64500, ts) - second.PeerIP = secondV4PeerIP - routes = append(routes, first, second) - } - - stored = append(stored, prefix) - } - - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - slices.Sort(stored) - - maskLength := routes[0].MaskLength - - sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV4, sampleLimit) - if err != nil { - t.Fatalf("GetRandomPrefixesByLength: %v", err) - } - - listed := make([]string, 0, len(sample)) - for _, route := range sample { - listed = append(listed, route.Prefix) - } - - checkPrefixSample(t, "GetRandomPrefixesByLength", listed, stored) - - looked, err := db.samplePrefixesByLength( - context.Background(), liveRoutesV4Table, maskLength, sampleLimit, nil) - if err != nil { - t.Fatalf("samplePrefixesByLength: %v", err) - } - - checkPrefixSample(t, "samplePrefixesByLength", looked, stored) -} - -// checkPrefixSample checks that sample lists sampleLimit prefixes, each once -// and each one of stored, and that it is not just the first sampleLimit of -// stored, which is in text order. -func checkPrefixSample(t *testing.T, name string, sample, stored []string) { - t.Helper() - - if len(sample) != sampleLimit { - t.Errorf("%s listed %d prefixes, want %d", name, len(sample), sampleLimit) - } - - listed := make(map[string]bool) - - for _, prefix := range sample { - if listed[prefix] || !slices.Contains(stored, prefix) { - t.Errorf("%s lists %s twice or it is not one of the /24s", name, prefix) - } - - listed[prefix] = true - } - - if slices.Equal(slices.Sorted(slices.Values(sample)), stored[:sampleLimit]) { - t.Errorf("%s lists just the first %d prefixes in text order", - name, sampleLimit) - } -} - -// TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes stores more prefixes -// of one mask length than maxPrefixesToShuffle. It checks that -// firstPrefixesByLength reads just the first maxPrefixesToShuffle+1 in text -// order, and that the sample lists the prefixes asked for, each once, some of -// them after those first ones, which only the random lookups reach. -func TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - routes := make([]*LiveRoute, 0, largeSamplePrefixCount) - stored := make([]string, 0, largeSamplePrefixCount) - - for i := range largeSamplePrefixCount { - route := mkV6Route(fmt.Sprintf("2001:%x::/32", i), 64500, ts) - routes = append(routes, route) - stored = append(stored, route.Prefix) - } - - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - slices.Sort(stored) - firstPrefixes := stored[:maxPrefixesToShuffle+1] - maskLength := routes[0].MaskLength - - read, err := db.firstPrefixesByLength(context.Background(), - liveRoutesV6Table, maskLength, maxPrefixesToShuffle+1) - if err != nil { - t.Fatalf("firstPrefixesByLength: %v", err) - } - - if !slices.Equal(read, firstPrefixes) { - t.Errorf("firstPrefixesByLength read %d prefixes, want the first %d "+ - "in text order", len(read), len(firstPrefixes)) - } - - sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV6, - largeSampleLimit) - if err != nil { - t.Fatalf("GetRandomPrefixesByLength: %v", err) - } - - if len(sample) != largeSampleLimit { - t.Errorf("GetRandomPrefixesByLength listed %d prefixes, want %d", - len(sample), largeSampleLimit) - } - - listed := make(map[string]bool) - pastFirstPrefixes := 0 - - for _, route := range sample { - if listed[route.Prefix] { - t.Errorf("GetRandomPrefixesByLength lists %s twice", route.Prefix) - } - - listed[route.Prefix] = true - - if !slices.Contains(firstPrefixes, route.Prefix) { - pastFirstPrefixes++ - } - } - - if pastFirstPrefixes == 0 { - t.Errorf("GetRandomPrefixesByLength lists only prefixes among the first "+ - "%d in text order", len(firstPrefixes)) - } -} - -// TestSamplePrefixesByLengthFillsUpFromShuffled stores a single prefix, so -// every random lookup lands on it, and checks that the sample still lists -// sampleLimit prefixes: the stored one, then the shuffled ones in order. The -// stored prefix is the second shuffled one, so the fill-up must skip it. -func TestSamplePrefixesByLengthFillsUpFromShuffled(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - shuffled := make([]string, 0, sampledPrefixCount) - for i := range sampledPrefixCount { - shuffled = append(shuffled, fmt.Sprintf("10.0.%d.0/24", i)) - } - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - route := mkV4Route(t, shuffled[1], 64500, ts) - - err = db.UpsertLiveRouteBatch([]*LiveRoute{route}) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - sample, err := db.samplePrefixesByLength(context.Background(), - liveRoutesV4Table, route.MaskLength, sampleLimit, shuffled) - if err != nil { - t.Fatalf("samplePrefixesByLength: %v", err) - } - - want := append([]string{shuffled[1], shuffled[0]}, shuffled[2:sampleLimit]...) - if !slices.Equal(sample, want) { - t.Errorf("samplePrefixesByLength = %v, want %v", sample, want) - } -} - -// TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes withdraws a route after -// storing it, as can happen between the steps of GetRandomPrefixesByLength. It -// checks that the random lookups pick nothing for a mask length with no route -// left, and that only the prefixes that still have a route get their newest -// route listed. -func TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes(t *testing.T) { - t.Parallel() - - cfg := &config.Config{StateDir: t.TempDir()} - - db, err := New(cfg, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - kept := mkV4Route(t, "198.51.100.0/24", 64500, ts) - withdrawn := mkV4Route(t, "172.16.0.0/16", 64500, ts) - - err = db.UpsertLiveRouteBatch([]*LiveRoute{kept, withdrawn}) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - err = db.DeleteLiveRoute(withdrawn.Prefix, withdrawn.PeerIP) - if err != nil { - t.Fatalf("DeleteLiveRoute: %v", err) - } - - ctx := context.Background() - - sample, err := db.samplePrefixesByLength(ctx, liveRoutesV4Table, - withdrawn.MaskLength, sampleLimit, []string{withdrawn.Prefix}) - if err != nil { - t.Fatalf("samplePrefixesByLength: %v", err) - } - - if len(sample) != 0 { - t.Errorf("samplePrefixesByLength = %v, want none for a mask length "+ - "with no route left", sample) - } - - routes, err := db.newestRouteOfEachPrefix(ctx, liveRoutesV4Table, - ipVersionV4, []string{withdrawn.Prefix, kept.Prefix}) - if err != nil { - t.Fatalf("newestRouteOfEachPrefix: %v", err) - } - - if len(routes) != 1 || routes[0].Prefix != kept.Prefix { - t.Errorf("newestRouteOfEachPrefix = %+v, want only the route of %s", - routes, kept.Prefix) - } -} - -// TestLiveRouteUpdateSetsOriginWhenItChanges announces a peer's route for a -// prefix, then again with the same origin AS and a new path, then with a new -// origin AS. Each time the route must carry the announced origin AS, path and -// time, and the AS details must list the prefix under that origin AS only. The -// update sets the origin AS with a statement of its own, which changes nothing -// while the origin AS stays the same. -func TestLiveRouteUpdateSetsOriginWhenItChanges(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - const firstOrigin, secondOrigin = 64500, 64501 - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - for _, asn := range []int{firstOrigin, secondOrigin} { - _, err := db.GetOrCreateASN(asn, ts) - if err != nil { - t.Fatalf("GetOrCreateASN(%d): %v", asn, err) - } - } - - route := mkV4Route(t, "198.51.100.0/24", firstOrigin, ts) - - for _, announced := range []struct { - origin int - path []int - }{ - {firstOrigin, []int{firstOrigin}}, - {firstOrigin, []int{secondOrigin, firstOrigin}}, - {secondOrigin, []int{firstOrigin, secondOrigin}}, - } { - ts = ts.Add(time.Minute) - route.OriginASN, route.ASPath, route.LastUpdated = - announced.origin, announced.path, ts - - err := db.UpsertLiveRouteBatch([]*LiveRoute{route}) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - routes, _, err := db.GetPrefixDetails(route.Prefix) - if err != nil { - t.Fatalf("GetPrefixDetails: %v", err) - } - - if len(routes) != 1 || routes[0].OriginASN != announced.origin || - !slices.Equal(routes[0].ASPath, announced.path) || - !routes[0].LastUpdated.Equal(ts) { - t.Errorf("after announcing origin AS%d, path %v at %s: routes %+v", - announced.origin, announced.path, ts, routes) - } - - for _, asn := range []int{firstOrigin, secondOrigin} { - _, prefixes, err := db.GetASDetails(asn) - if err != nil { - t.Fatalf("GetASDetails(%d): %v", asn, err) - } - - listed := len(prefixes) == 1 && prefixes[0].Prefix == route.Prefix - if listed != (asn == announced.origin) { - t.Errorf("after announcing origin AS%d, AS%d lists %+v", - announced.origin, asn, prefixes) - } - } - } -} - -// TestGetASDetailsListsEachPrefixOnce stores two IPv4 prefixes and an IPv6 -// prefix an AS originates, two of them from two peers at different times, and -// a prefix another AS originates. The AS details list each of the AS's -// prefixes once, IPv4 first and in text order, with its mask length and the -// time of its newest route. -func TestGetASDetailsListsEachPrefixOnce(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - const asn, otherASN = 64500, 64501 - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - _, err = db.GetOrCreateASN(asn, ts) - if err != nil { - t.Fatalf("GetOrCreateASN: %v", err) - } - - v4Older := mkV4Route(t, "192.0.2.0/24", asn, ts) - v4Newer := mkV4Route(t, "192.0.2.0/24", asn, ts.Add(time.Minute)) - v4Newer.PeerIP = secondV4PeerIP - v4Single := mkV4Route(t, "198.51.100.128/25", asn, ts) - v6Newer := mkV6Route(testV6Prefix, asn, ts.Add(time.Minute)) - v6Older := mkV6Route(testV6Prefix, asn, ts) - v6Older.PeerIP = secondV6PeerIP - other := mkV4Route(t, "203.0.113.0/24", otherASN, ts) - - err = db.UpsertLiveRouteBatch([]*LiveRoute{ - v4Older, v4Newer, v4Single, v6Newer, v6Older, other, - }) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - _, prefixes, err := db.GetASDetails(asn) - if err != nil { - t.Fatalf("GetASDetails: %v", err) - } - - want := []*LiveRoute{v4Newer, v4Single, v6Newer} - - listedOnce := slices.EqualFunc(prefixes, want, - func(got LiveRoute, want *LiveRoute) bool { - return got.Prefix == want.Prefix && - got.MaskLength == want.MaskLength && - got.IPVersion == want.IPVersion && - got.OriginASN == asn && - got.LastUpdated.Equal(want.LastUpdated) - }) - if !listedOnce { - t.Errorf("GetASDetails listed %+v, want the prefixes and times of %+v", - prefixes, want) - } -} - -// TestGetASDetailsOfASNotInDatabase asks for ASes that the database has no -// record of, some with stored routes. Each gets the prefixes of its routes and -// no seen times. One that the AS info lookup knows gets its handle and -// description from it. An AS with no record, no lookup entry and no route is -// ErrNoRoute. Before the fix every AS with no record was ErrNoRoute. -func TestGetASDetailsOfASNotInDatabase(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - tests := []struct { - asn int - listed bool // in the AS info lookup - prefixes []string // of the routes stored for the AS - }{ - {15169, true, []string{"192.0.2.0/24"}}, - {13335, true, nil}, - {4200000000, false, []string{"198.51.100.0/24"}}, - } - - var routes []*LiveRoute - - for _, tt := range tests { - if _, ok := asinfo.Get(tt.asn); ok != tt.listed { - t.Fatalf("AS%d in the AS info data: %t, want %t", tt.asn, ok, tt.listed) - } - - for _, prefix := range tt.prefixes { - routes = append(routes, mkV4Route(t, prefix, tt.asn, ts)) - } - } - - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - for _, tt := range tests { - got, prefixes, err := db.GetASDetails(tt.asn) - if err != nil { - t.Errorf("GetASDetails(%d): %v", tt.asn, err) - - continue - } - - want := ASN{ASN: tt.asn} - if info, ok := asinfo.Get(tt.asn); ok { - want.Handle, want.Description = info.Handle, info.Description - } - - var gotPrefixes []string - for _, route := range prefixes { - gotPrefixes = append(gotPrefixes, route.Prefix) - } - - if *got != want || !slices.Equal(gotPrefixes, tt.prefixes) { - t.Errorf("GetASDetails(%d) = %+v with prefixes %v, want %+v with %v", - tt.asn, *got, gotPrefixes, want, tt.prefixes) - } - } - - const absentASN = 4200000001 // no record, no lookup entry and no route - - if _, ok := asinfo.Get(absentASN); ok { - t.Fatalf("AS%d is in the AS info data", absentASN) - } - - _, _, err = db.GetASDetails(absentASN) - if !errors.Is(err, ErrNoRoute) { - t.Errorf("GetASDetails(%d): error %v, want %v", absentASN, err, ErrNoRoute) - } -} - // TestGetASReadsTheRecordOrTheBuiltInList asks GetAS for an AS the asns table // holds, one only the AS info lookup holds, and one neither holds. The first // gets its record's seen times, the second its handle and description from the @@ -1939,78 +1097,6 @@ func TestGetPrefixFirstSeenReadsThePrefixTables(t *testing.T) { } } -// TestASPrefixQueriesUseCoveringIndexes checks the plan of each query that -// lists the prefixes an AS originates: a search of a covering index and no -// temporary B-tree, so the list reads one index entry per prefix, not one -// table row per route. Grouping the AS's routes by prefix read every route's -// row and sorted them in a temporary B-tree -// (https://git.eeqj.de/sneak/routewatch/issues/104). -func TestASPrefixQueriesUseCoveringIndexes(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - tests := []struct { - query string - args []any - index string - }{ - {nextOriginPrefixV4SQL, []any{0, ""}, "idx_live_routes_v4_origin_prefix"}, - {newestRouteTimeV4SQL, []any{""}, "idx_live_routes_v4_prefix_last_updated"}, - {nextOriginPrefixV6SQL, []any{0, ""}, "idx_live_routes_v6_origin_prefix"}, - {newestRouteTimeV6SQL, []any{""}, "idx_live_routes_v6_prefix_last_updated"}, - } - - for _, tt := range tests { - plan := queryPlan(t, db, tt.query, tt.args...) - - if !strings.Contains(plan, "USING COVERING INDEX "+tt.index+" ") || - strings.Contains(plan, "TEMP B-TREE") { - t.Errorf("plan of %s:\n%s\nwant a search of covering index %s "+ - "and no temporary B-tree", tt.query, plan, tt.index) - } - } -} - -// queryPlan returns the steps of EXPLAIN QUERY PLAN for query, one per line. -func queryPlan(t *testing.T, db *Database, query string, args ...any) string { - t.Helper() - - rows, err := db.db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query, - args...) - if err != nil { - t.Fatalf("EXPLAIN QUERY PLAN %s: %v", query, err) - } - defer func() { _ = rows.Close() }() - - var steps []string - - for rows.Next() { - var ( - id, parent, unused int - detail string - ) - - err := rows.Scan(&id, &parent, &unused, &detail) - if err != nil { - t.Fatalf("reading the plan of %s: %v", query, err) - } - - steps = append(steps, detail) - } - - err = rows.Err() - if err != nil { - t.Fatalf("reading the plan of %s: %v", query, err) - } - - return strings.Join(steps, "\n") -} - // TestUpdatePrefixesBatchKeepsFirstSeen writes a prefix, then a batch with it // and a new prefix. Only the new prefix is inserted and counted, and the known // one keeps its first_seen time. @@ -2271,304 +1357,3 @@ func TestGetOrCreateASNReturnsStoredSeenTimes(t *testing.T) { got.FirstSeen, got.LastSeen, older, newer) } } - -// TestUpsertLiveRouteBatchWritesInKeyOrder writes new routes of two peers out -// of key order, the last one a second announcement of the first, and checks -// that the rows were inserted in key order, peer then prefix (each new row's id -// is one above the last), and that the route announced twice carries the later -// announcement. -func TestUpsertLiveRouteBatchWritesInKeyOrder(t *testing.T) { - t.Parallel() - - db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New()) - if err != nil { - t.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - const ( - firstOrigin, laterOrigin = 64500, 64501 - // Three prefixes, in text order. - firstPrefix = "192.0.2.0/24" - secondPrefix = "198.51.100.0/24" - thirdPrefix = "203.0.113.0/24" - ) - - type row struct { - peer, prefix string - origin int - } - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - announcements := []row{ - {secondV4PeerIP, secondPrefix, firstOrigin}, - {testV4PeerIP, thirdPrefix, firstOrigin}, - {secondV4PeerIP, firstPrefix, firstOrigin}, - {testV4PeerIP, secondPrefix, firstOrigin}, - {secondV4PeerIP, secondPrefix, laterOrigin}, - } - - routes := make([]*LiveRoute, 0, len(announcements)) - - for _, announced := range announcements { - route := mkV4Route(t, announced.prefix, announced.origin, ts) - route.PeerIP = announced.peer - routes = append(routes, route) - } - - err = db.UpsertLiveRouteBatch(routes) - if err != nil { - t.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - rows, err := db.db.QueryContext(t.Context(), - "SELECT peer_ip, prefix, origin_asn FROM live_routes_v4 ORDER BY id") - if err != nil { - t.Fatalf("reading the routes: %v", err) - } - defer func() { _ = rows.Close() }() - - var stored []row - - for rows.Next() { - var r row - - err := rows.Scan(&r.peer, &r.prefix, &r.origin) - if err != nil { - t.Fatalf("reading a route: %v", err) - } - - stored = append(stored, r) - } - - err = rows.Err() - if err != nil { - t.Fatalf("reading the routes: %v", err) - } - - want := []row{ - {testV4PeerIP, secondPrefix, firstOrigin}, - {testV4PeerIP, thirdPrefix, firstOrigin}, - {secondV4PeerIP, firstPrefix, firstOrigin}, - {secondV4PeerIP, secondPrefix, laterOrigin}, - } - - if !slices.Equal(stored, want) { - t.Errorf("routes in insertion order = %+v, want %+v", stored, want) - } -} - -// Parameters for BenchmarkPrefixBatchOnFiveMillionRoutes. -const ( - // benchPeers and benchPrefixes size the table the batches are written to: - // every peer has a route for every prefix, 5 million routes in all. - benchPeers = 10 - benchPrefixes = 500000 - // benchBatchSize is how many updates each batch holds, as many as a full - // prefix handler batch (prefixBatchSize in internal/routewatch). - benchBatchSize = 25000 - // benchMaxBatchSize is the largest batch the prefix handler writes, the - // one it holds while a slow write runs (prefixBatchMaxSize in - // internal/routewatch). - benchMaxBatchSize = 250000 - // benchWithdrawalEvery makes every fifth update of a timed batch a - // withdrawal; the others are announcements. - benchWithdrawalEvery = 5 - // benchOrigins is how many origin ASes the prefixes are spread over. - benchOrigins = 50000 - // benchFirstASN is the lowest AS number in the benchmark's AS paths. - benchFirstASN = 64500 - // benchMaskLength is the mask length of every prefix in the benchmark. - benchMaskLength = 24 -) - -// BenchmarkPrefixBatchOnFiveMillionRoutes times the writes the prefix handler -// makes for one batch of announcements and withdrawals of routes picked at -// random, to a database that already holds 5 million live routes: 500,000 -// IPv4 prefixes, each from 10 peers. It times a full batch of 25,000 updates, -// then the largest batch, 250,000. It builds that database first, one batch at -// a time, which takes some minutes and a few GB of disk under TMPDIR. It is -// run by hand, not by make test, for example in the test image: -// -// go test -run '^$' -bench FiveMillion -benchtime 10x ./internal/database/ -func BenchmarkPrefixBatchOnFiveMillionRoutes(b *testing.B) { - db, err := New(&config.Config{StateDir: b.TempDir()}, logger.New()) - if err != nil { - b.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - //nolint:gosec // The benchmark needs the same batches every run. - rng := rand.New(rand.NewPCG(1, 2)) - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - // Each peer sends its whole table, as when its session comes up. - for peer := range benchPeers { - for first := 0; first < benchPrefixes; first += benchBatchSize { - routes := make([]*LiveRoute, 0, benchBatchSize) - for prefix := first; prefix < first+benchBatchSize; prefix++ { - routes = append(routes, benchRoute(rng, prefix, peer, ts)) - } - - writeBenchBatch(b, db, routes, nil, ts) - } - } - - for _, batchSize := range []int{benchBatchSize, benchMaxBatchSize} { - b.Run(strconv.Itoa(batchSize), func(b *testing.B) { - for b.Loop() { - ts = ts.Add(time.Second) - - var ( - routes []*LiveRoute - withdrawals []LiveRouteDeletion - ) - - for i := range batchSize { - route := benchRoute(rng, rng.IntN(benchPrefixes), - rng.IntN(benchPeers), ts) - if i%benchWithdrawalEvery == 0 { - withdrawals = append(withdrawals, LiveRouteDeletion{ - Prefix: route.Prefix, PeerIP: route.PeerIP, - IPVersion: ipVersionV4, - }) - } else { - routes = append(routes, route) - } - } - - writeBenchBatch(b, db, routes, withdrawals, ts) - } - }) - } -} - -// benchRoute returns the route peer has for the prefix numbered prefix, a /24 -// from 1.0.0.0/24 on, with a new AS path of three to six ASes from the peer's -// AS to the prefix's origin AS. -func benchRoute(rng *rand.Rand, prefix, peer int, ts time.Time) *LiveRoute { - const minPathLength, extraPathLength = 3, 4 - - origin := benchFirstASN + prefix%benchOrigins - pathLength := minPathLength + rng.IntN(extraPathLength) - - path := make([]int, 0, pathLength) - path = append(path, benchFirstASN+peer) - - for len(path) < pathLength-1 { - path = append(path, benchFirstASN+rng.IntN(benchOrigins)) - } - - path = append(path, origin) - peerIP := fmt.Sprintf("198.51.100.%d", 1+peer) - - return &LiveRoute{ - Prefix: fmt.Sprintf("%d.%d.%d.0/%d", - 1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength), - MaskLength: benchMaskLength, - IPVersion: ipVersionV4, - OriginASN: origin, - PeerIP: peerIP, - ASPath: path, - NextHop: peerIP, - LastUpdated: ts, - } -} - -// writeBenchBatch writes what the prefix handler writes for a batch: the -// announced routes, the withdrawals, and every prefix the batch names, of -// which only the ones not stored yet are inserted. -func writeBenchBatch( - b *testing.B, db *Database, routes []*LiveRoute, - withdrawals []LiveRouteDeletion, ts time.Time, -) { - b.Helper() - - prefixes := make(map[string]time.Time, len(routes)+len(withdrawals)) - for _, route := range routes { - prefixes[route.Prefix] = ts - } - - for _, withdrawal := range withdrawals { - prefixes[withdrawal.Prefix] = ts - } - - err := db.UpsertLiveRouteBatch(routes) - if err != nil { - b.Fatalf("UpsertLiveRouteBatch: %v", err) - } - - err = db.DeleteLiveRouteBatch(withdrawals) - if err != nil { - b.Fatalf("DeleteLiveRouteBatch: %v", err) - } - - err = db.UpdatePrefixesBatch(prefixes) - if err != nil { - b.Fatalf("UpdatePrefixesBatch: %v", err) - } -} - -// Parameters for BenchmarkGetASDetailsWithFiveHundredPeers: the AS originates -// benchASPrefixes prefixes and every one of benchASPeers peers has a route for -// each, 2.5 million routes in all. -const ( - benchASPrefixes = 5000 - benchASPeers = 500 -) - -// BenchmarkGetASDetailsWithFiveHundredPeers times the read behind the AS page -// for an AS that originates 5,000 IPv4 prefixes, each from 500 peers. It -// builds that database first, one peer at a time, which takes some minutes and -// about a GB of disk under TMPDIR. It is run by hand, not by make test, for -// example in the test image: -// -// go test -run '^$' -bench GetASDetails -benchtime 10x ./internal/database/ -func BenchmarkGetASDetailsWithFiveHundredPeers(b *testing.B) { - db, err := New(&config.Config{StateDir: b.TempDir()}, logger.New()) - if err != nil { - b.Fatalf("failed to create database: %v", err) - } - defer func() { _ = db.Close() }() - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - - _, err = db.GetOrCreateASN(benchFirstASN, ts) - if err != nil { - b.Fatalf("GetOrCreateASN: %v", err) - } - - for peer := range benchASPeers { - peerIP := fmt.Sprintf("198.18.%d.%d", peer/256, peer%256) - - routes := make([]*LiveRoute, 0, benchASPrefixes) - for prefix := range benchASPrefixes { - routes = append(routes, &LiveRoute{ - Prefix: fmt.Sprintf("%d.%d.%d.0/%d", - 1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength), - MaskLength: benchMaskLength, - IPVersion: ipVersionV4, - OriginASN: benchFirstASN, - PeerIP: peerIP, - ASPath: []int{benchFirstASN + 1 + peer, benchFirstASN}, - NextHop: peerIP, - LastUpdated: ts, - }) - } - - writeBenchBatch(b, db, routes, nil, ts) - } - - for b.Loop() { - _, prefixes, err := db.GetASDetails(benchFirstASN) - if err != nil { - b.Fatalf("GetASDetails: %v", err) - } - - if len(prefixes) != benchASPrefixes { - b.Fatalf("GetASDetails listed %d prefixes, want %d", - len(prefixes), benchASPrefixes) - } - } -} diff --git a/internal/database/interface.go b/internal/database/interface.go index 195c267..895297f 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -1,7 +1,6 @@ -// Package database provides SQLite storage for BGP routing data including ASNs, -// prefixes, announcements, peerings, and live route tables. Its routes table -// holds a copy of the whole routing table that internal/routetable keeps in -// memory. +// Package database provides SQLite storage for BGP routing data: ASNs, +// prefixes, announcements, peerings and BGP peers. Its routes table holds a +// copy of the whole routing table that internal/routetable keeps in memory. package database import ( @@ -12,26 +11,21 @@ import ( "git.eeqj.de/sneak/routewatch/internal/routetable" ) -// Stats contains database statistics including counts for ASNs, prefixes, -// peerings, peers, and live routes, as well as file size and prefix distribution data. +// Stats contains database statistics: counts of ASNs, prefixes, peerings and +// peers, and the file size. type Stats struct { - ASNs int - Prefixes int - IPv4Prefixes int - IPv6Prefixes int - Peerings int - Peers int - FileSizeBytes int64 - LiveRoutes int - IPv4Routes int - IPv6Routes int - IPv4PrefixDistribution []PrefixDistribution - IPv6PrefixDistribution []PrefixDistribution + ASNs int + Prefixes int + IPv4Prefixes int + IPv6Prefixes int + Peerings int + Peers int + FileSizeBytes int64 } // Store defines the interface for database operations. It provides methods for -// managing ASNs, prefixes, announcements, peerings, BGP peers, and live routes. -// Implementations must be safe for concurrent use. +// managing ASNs, prefixes, announcements, peerings, BGP peers, and the copy of +// the routing table. Implementations must be safe for concurrent use. // //nolint:interfacebloat // the one seam handlers and tests depend on type Store interface { @@ -57,27 +51,10 @@ type Store interface { UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error UpdatePeerBatch(peers map[string]PeerUpdate) error - // Live route operations - UpsertLiveRoute(route *LiveRoute) error - UpsertLiveRouteBatch(routes []*LiveRoute) error - DeleteLiveRoute(prefix, peerIP string) error - DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error - DeleteLiveRoutesOfPeer(peerIP string) (deleted int, err error) - GetPrefixDistribution() ( - ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) - GetPrefixDistributionContext(ctx context.Context) ( - ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) - GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) - GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error) - // Routing table operations: the database's copy of the table in memory WriteRouteChanges(changes []routetable.Change, newPeers map[string]uint) error ReadRouteTable(ctx context.Context) (*routetable.Table, error) - // IP lookup operations - GetIPInfo(ip string) (*IPInfo, error) - GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) - // ASN WHOIS operations GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error) UpdateASNWHOIS(ctx context.Context, update *ASNWHOISUpdate) error @@ -87,18 +64,8 @@ type Store interface { GetAS(ctx context.Context, asn int) (*ASN, bool, error) GetASWHOIS(ctx context.Context, asn int) (*ASN, error) GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error) - GetASDetails(asn int) (*ASN, []LiveRoute, error) - GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error) GetASPeers(asn int) ([]ASPeer, error) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error) - GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) - GetPrefixDetailsContext( - ctx context.Context, prefix string, - ) ([]LiveRoute, map[int]ASN, error) - GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) - GetRandomPrefixesByLengthContext( - ctx context.Context, maskLength, ipVersion, limit int, - ) ([]LiveRoute, error) // Lifecycle Close() error diff --git a/internal/database/models.go b/internal/database/models.go index 36cc035..92c8f67 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -66,65 +66,6 @@ type ASNPeering struct { LastSeen time.Time } -// LiveRoute represents a route in the live routing table. JSON leaves out an -// empty AS path, next hop or time: the routing table in memory keeps them only -// for the two newest routes of each prefix and origin AS. -// -//nolint:tagliatelle // field names are the existing JSON API -type LiveRoute struct { - Prefix string `json:"prefix"` - MaskLength int `json:"mask_length"` - IPVersion int `json:"ip_version"` - OriginASN int `json:"origin_asn"` - PeerIP string `json:"peer_ip"` - ASPath []int `json:"as_path,omitempty"` - NextHop string `json:"next_hop,omitempty"` - LastUpdated time.Time `json:"last_updated,omitzero"` -} - -// PrefixDistribution represents the distribution of prefixes by mask length -// -//nolint:tagliatelle // field names are the existing JSON API -type PrefixDistribution struct { - MaskLength int `json:"mask_length"` - Count int `json:"count"` -} - -// IPInfo represents comprehensive IP information for the /ip endpoint -// -//nolint:tagliatelle // field names are the existing JSON API -type IPInfo struct { - IP string `json:"ip"` - Netblock string `json:"netblock"` - MaskLength int `json:"mask_length"` - IPVersion int `json:"ip_version"` - NumPeers int `json:"num_peers"` - // AS information - ASN int `json:"asn"` - ASName string `json:"as_name,omitempty"` - Handle string `json:"handle,omitempty"` - Description string `json:"description,omitempty"` - OrgName string `json:"org_name,omitempty"` - OrgID string `json:"org_id,omitempty"` - Address string `json:"address,omitempty"` - CountryCode string `json:"country_code,omitempty"` - AbuseEmail string `json:"abuse_email,omitempty"` - RIR string `json:"rir,omitempty"` - // Timestamps. LastSeen is zero, and left out of JSON, when the prefix - // keeps no route. - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen,omitzero"` - // Indicates if WHOIS data needs refresh (not serialized) - NeedsWHOISRefresh bool `json:"-"` -} - -// LiveRouteDeletion represents parameters for deleting a live route -type LiveRouteDeletion struct { - Prefix string - PeerIP string - IPVersion int -} - // SeenTimes is the earliest and the latest time one batch of updates saw an AS, // a peer or a peering. A row the batch adds is stored first seen at First and // last seen at Last. A row already stored takes First as its first_seen only diff --git a/internal/database/schema.sql b/internal/database/schema.sql index cf8f1c6..180c347 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -89,65 +89,6 @@ CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn); CREATE INDEX IF NOT EXISTS idx_bgp_peers_last_seen ON bgp_peers(last_seen); CREATE INDEX IF NOT EXISTS idx_bgp_peers_ip ON bgp_peers(peer_ip); --- IPv4 routing table maintained by PrefixHandler. A route write changes the --- table and its entry in every index on a column the write sets, so the table --- has only the indexes that the queries named with them use. -CREATE TABLE IF NOT EXISTS live_routes_v4 ( - -- The row id, which the prefix-length pages' sample draws through the - -- mask_length index. Declared as a column, it stays the same for as long - -- as the route is live, also through a VACUUM. - id INTEGER PRIMARY KEY, - prefix TEXT NOT NULL, - mask_length INTEGER NOT NULL, -- CIDR mask length (0-32) - origin_asn INTEGER NOT NULL, - peer_ip TEXT NOT NULL, - as_path TEXT NOT NULL, -- JSON array - next_hop TEXT NOT NULL, - last_updated DATETIME NOT NULL, - -- A peer has at most one route for a prefix. This key finds the route a - -- write updates or deletes and, peer_ip coming first, all of a peer's - -- routes when its BGP session goes down. - UNIQUE(peer_ip, prefix) -); - --- IPv6 routing table maintained by PrefixHandler, as live_routes_v4. -CREATE TABLE IF NOT EXISTS live_routes_v6 ( - id INTEGER PRIMARY KEY, - prefix TEXT NOT NULL, - mask_length INTEGER NOT NULL, -- CIDR mask length (0-128) - origin_asn INTEGER NOT NULL, - peer_ip TEXT NOT NULL, - as_path TEXT NOT NULL, -- JSON array - next_hop TEXT NOT NULL, - last_updated DATETIME NOT NULL, - UNIQUE(peer_ip, prefix) -); - --- Indexes for both live route tables, and the queries that use each. --- --- prefix, last_updated: a prefix's routes, for the prefix page and its API --- answer, the IP lookup and its peer count, and the check whether a prefix --- still has a live route; and a prefix's newest route for the prefix-length --- pages and the AS page, in one search whatever the number of peers. -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_prefix_last_updated ON live_routes_v4(prefix, last_updated); -CREATE INDEX IF NOT EXISTS idx_live_routes_v6_prefix_last_updated ON live_routes_v6(prefix, last_updated); --- mask_length: the prefix-length pages' random sample, the row id range of a --- mask length and the first route of it at or after a row id. -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_length ON live_routes_v4(mask_length); -CREATE INDEX IF NOT EXISTS idx_live_routes_v6_mask_length ON live_routes_v6(mask_length); --- mask_length, prefix: the prefix-length pages' list of a mask length's --- prefixes in text order, and the prefix distribution read at startup. -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_prefix ON live_routes_v4(mask_length, prefix); -CREATE INDEX IF NOT EXISTS idx_live_routes_v6_mask_prefix ON live_routes_v6(mask_length, prefix); --- origin_asn, prefix: the prefixes an AS originates, each in one search --- whatever the number of peers, for the AS page and its API answer. -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_origin_prefix ON live_routes_v4(origin_asn, prefix); -CREATE INDEX IF NOT EXISTS idx_live_routes_v6_origin_prefix ON live_routes_v6(origin_asn, prefix); --- last_updated: the oldest and newest route times in /api/v1/stats and --- /status.json. -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated); -CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated); - -- The whole routing table, as internal/routetable keeps it in memory: one row -- for each prefix and origin AS that some peer carries. Startup reads every -- row, and the rows of the pairs that change are written back. diff --git a/internal/database/utils.go b/internal/database/utils.go index 4ba0b0d..cf1ff34 100644 --- a/internal/database/utils.go +++ b/internal/database/utils.go @@ -1,8 +1,6 @@ package database import ( - "fmt" - "net" "strings" "github.com/google/uuid" @@ -20,15 +18,3 @@ func detectIPVersion(prefix string) int { return ipVersionV4 } - -// prefixMaskLength returns the mask length of a prefix such as 192.0.2.0/24. -func prefixMaskLength(prefix string) (int, error) { - _, network, err := net.ParseCIDR(prefix) - if err != nil { - return 0, fmt.Errorf("invalid prefix %s: %w", prefix, err) - } - - maskLength, _ := network.Mask.Size() - - return maskLength, nil -} diff --git a/internal/routewatch/app.go b/internal/routewatch/app.go index 7832669..91b2c41 100644 --- a/internal/routewatch/app.go +++ b/internal/routewatch/app.go @@ -109,8 +109,9 @@ func (rw *RouteWatch) Run(ctx context.Context) error { rw.peerHandler = NewPeerHandler(rw.db, rw.logger) rw.streamer.RegisterHandler(rw.peerHandler) - // PrefixHandler maintains the prefixes and live_routes tables, and - // the routing table in memory and its copy in the routes table + // PrefixHandler keeps the routing table in memory current, and writes + // its changes to the routes table and new prefixes to the prefix + // tables rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger, rw.table) rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker()) rw.streamer.RegisterHandler(rw.prefixHandler) @@ -125,8 +126,6 @@ func (rw *RouteWatch) Run(ctx context.Context) error { return errNonBatchedNotImplemented } - // No longer need routing table handler - PrefixHandler maintains live_routes table - // Start streaming err := rw.streamer.Start(ctx) if err != nil { diff --git a/internal/routewatch/app_integration_internal_test.go b/internal/routewatch/app_integration_internal_test.go index f6743e9..f736dc2 100644 --- a/internal/routewatch/app_integration_internal_test.go +++ b/internal/routewatch/app_integration_internal_test.go @@ -33,14 +33,12 @@ type mockStore struct { ASNCount int PrefixCount int PeeringCount int - RouteCount int WithdrawalCount int // Track unique items ASNs map[int]*database.ASN Prefixes map[string]*database.Prefix Peerings map[string]bool // key is "from_to" - Routes map[string]bool // key is "prefix_origin_peer" // TablePrefixes holds the prefixes that the written changes of the // routing table leave some peer carrying. TablePrefixes map[string]bool @@ -56,7 +54,6 @@ func newMockStore() *mockStore { ASNs: make(map[int]*database.ASN), Prefixes: make(map[string]*database.Prefix), Peerings: make(map[string]bool), - Routes: make(map[string]bool), TablePrefixes: make(map[string]bool), } } @@ -107,6 +104,13 @@ func (m *mockStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error { IPVersion: ipVersion, FirstSeen: timestamp, } + m.PrefixCount++ + + if ipVersion == ipVersionV4 { + m.IPv4Prefixes++ + } else { + m.IPv6Prefixes++ + } } } @@ -207,65 +211,6 @@ func (m *mockStore) GetStatsContext(_ context.Context) (database.Stats, error) { return m.GetStats() } -// UpsertLiveRoute mock implementation -func (m *mockStore) UpsertLiveRoute(_ *database.LiveRoute) error { - // Simple mock - just return nil - return nil -} - -// DeleteLiveRoute mock implementation -func (m *mockStore) DeleteLiveRoute(_, _ string) error { - // Simple mock - just return nil - return nil -} - -// GetPrefixDistribution mock implementation -func (m *mockStore) GetPrefixDistribution() ( - []database.PrefixDistribution, []database.PrefixDistribution, error, -) { - // Return empty distributions for now - return nil, nil, nil -} - -// GetPrefixDistributionContext mock implementation with context support -func (m *mockStore) GetPrefixDistributionContext(_ context.Context) ( - []database.PrefixDistribution, []database.PrefixDistribution, error, -) { - return m.GetPrefixDistribution() -} - -// GetLiveRouteCounts mock implementation -func (m *mockStore) GetLiveRouteCounts() (int, int, error) { - // Return mock counts - return m.RouteCount / 2, m.RouteCount / 2, nil -} - -// GetLiveRouteCountsContext mock implementation with context support -func (m *mockStore) GetLiveRouteCountsContext(_ context.Context) (int, int, error) { - return m.GetLiveRouteCounts() -} - -// GetASDetails mock implementation -func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) { - m.mu.Lock() - defer m.mu.Unlock() - - // Check if ASN exists - if asnInfo, exists := m.ASNs[asn]; exists { - // Return empty prefixes for now - return asnInfo, []database.LiveRoute{}, nil - } - - return nil, nil, database.ErrNoRoute -} - -// GetASDetailsContext mock implementation with context support -func (m *mockStore) GetASDetailsContext( - _ context.Context, asn int, -) (*database.ASN, []database.LiveRoute, error) { - return m.GetASDetails(asn) -} - // GetAS returns the stored AS, or only its number when none is stored. func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) { m.mu.Lock() @@ -292,35 +237,6 @@ func (m *mockStore) GetPrefixFirstSeen( return time.Time{}, nil } -// GetPrefixDetails mock implementation -func (m *mockStore) GetPrefixDetails( - _ string, -) ([]database.LiveRoute, map[int]database.ASN, error) { - // Return empty routes for now - return []database.LiveRoute{}, map[int]database.ASN{}, nil -} - -// GetPrefixDetailsContext mock implementation with context support -func (m *mockStore) GetPrefixDetailsContext( - _ context.Context, prefix string, -) ([]database.LiveRoute, map[int]database.ASN, error) { - return m.GetPrefixDetails(prefix) -} - -func (m *mockStore) GetRandomPrefixesByLength( - _, _, _ int, -) ([]database.LiveRoute, error) { - // Return empty routes for now - return []database.LiveRoute{}, nil -} - -// GetRandomPrefixesByLengthContext mock implementation with context support -func (m *mockStore) GetRandomPrefixesByLengthContext( - _ context.Context, maskLength, ipVersion, limit int, -) ([]database.LiveRoute, error) { - return m.GetRandomPrefixesByLength(maskLength, ipVersion, limit) -} - // GetASPeers mock implementation func (m *mockStore) GetASPeers(_ int) ([]database.ASPeer, error) { // Return empty peers for now @@ -334,32 +250,6 @@ func (m *mockStore) GetASPeersContext( return m.GetASPeers(asn) } -// GetIPInfo mock implementation -func (m *mockStore) GetIPInfo(ip string) (*database.IPInfo, error) { - return m.GetIPInfoContext(context.Background(), ip) -} - -// GetIPInfoContext mock implementation with context support -func (m *mockStore) GetIPInfoContext( - _ context.Context, ip string, -) (*database.IPInfo, error) { - now := time.Now() - - return &database.IPInfo{ - IP: ip, - Netblock: "8.8.8.0/24", - MaskLength: 24, - IPVersion: 4, - NumPeers: 3, - ASN: 15169, - Handle: "GOOGLE", - Description: "Google LLC", - CountryCode: "US", - FirstSeen: now.Add(-24 * time.Hour), - LastSeen: now, - }, nil -} - // GetNextStaleASN mock implementation func (m *mockStore) GetNextStaleASN(_ context.Context, _ time.Duration) (int, error) { return 0, database.ErrNoStaleASN @@ -387,46 +277,6 @@ func (m *mockStore) GetWHOISStats( }, nil } -// UpsertLiveRouteBatch mock implementation -func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error { - m.mu.Lock() - defer m.mu.Unlock() - - for _, route := range routes { - // Track prefix - if _, exists := m.Prefixes[route.Prefix]; !exists { - m.Prefixes[route.Prefix] = &database.Prefix{ - Prefix: route.Prefix, - IPVersion: route.IPVersion, - FirstSeen: route.LastUpdated, - } - m.PrefixCount++ - - if route.IPVersion == 4 { - m.IPv4Prefixes++ - } else { - m.IPv6Prefixes++ - } - } - - m.RouteCount++ - } - - return nil -} - -// DeleteLiveRouteBatch mock implementation -func (m *mockStore) DeleteLiveRouteBatch(_ []database.LiveRouteDeletion) error { - // Simple mock - just return nil - return nil -} - -// DeleteLiveRoutesOfPeer mock implementation -func (m *mockStore) DeleteLiveRoutesOfPeer(_ string) (int, error) { - // Simple mock - deletes nothing - return 0, nil -} - // WriteRouteChanges records the prefixes the changes leave some peer carrying, // whatever their origin AS. func (m *mockStore) WriteRouteChanges( diff --git a/internal/routewatch/ashandler_internal_test.go b/internal/routewatch/ashandler_internal_test.go index 33094d1..3e028d0 100644 --- a/internal/routewatch/ashandler_internal_test.go +++ b/internal/routewatch/ashandler_internal_test.go @@ -29,9 +29,9 @@ func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) { h.HandleMessage(second) h.flushBatch() - asn, _, err := db.GetASDetails(testASNA) + asn, _, err := db.GetAS(t.Context(), testASNA) if err != nil { - t.Fatalf("GetASDetails(%d): %v", testASNA, err) + t.Fatalf("GetAS(%d): %v", testASNA, err) } if !asn.FirstSeen.Equal(first.ParsedTimestamp) { diff --git a/internal/routewatch/batchwrite_internal_test.go b/internal/routewatch/batchwrite_internal_test.go index 3a09aa6..e196fb9 100644 --- a/internal/routewatch/batchwrite_internal_test.go +++ b/internal/routewatch/batchwrite_internal_test.go @@ -81,9 +81,9 @@ func TestFlushLoopWritesOnEveryTick(t *testing.T) { } // blockingStore is a mockStore whose batch writes for the AS, peer and prefix -// handlers, delete of a peer's routes and peering write wait until the test -// calls releaseWrites. writeStarted is closed when the first of them starts. It -// records what each batch write was given. +// handlers and peering write wait until the test calls releaseWrites. +// writeStarted is closed when the first of them starts. It records what each +// batch write was given. type blockingStore struct { *mockStore @@ -109,13 +109,13 @@ func newBlockingStore() *blockingStore { } } -func (s *blockingStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error { +func (s *blockingStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error { s.waitForRelease() s.mu.Lock() defer s.mu.Unlock() - for _, route := range routes { - s.prefixes[route.Prefix] = true + for prefix := range prefixes { + s.prefixes[prefix] = true } return nil @@ -145,12 +145,6 @@ func (s *blockingStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) er return nil } -func (s *blockingStore) DeleteLiveRoutesOfPeer(peerIP string) (int, error) { - s.waitForRelease() - - return s.mockStore.DeleteLiveRoutesOfPeer(peerIP) -} - func (s *blockingStore) RecordPeering( asA, asB int, seen database.SeenTimes, ) error { @@ -176,7 +170,6 @@ func (s *blockingStore) releaseWrites() { type stopLog struct { Msg string `json:"msg"` Updates int `json:"updates"` - PeersDown int `json:"peers_down"` TableChanges int `json:"table_changes"` Paths int `json:"paths"` } @@ -374,10 +367,10 @@ func stopWhileAWriteIsBlocked( } // TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline also checks the -// routing table's changes: the flush whose delete is held wrote those of the -// fill, so Stop must log the one of the extra prefix as not written. The flush -// timer must not fire. A flush it started before the fill would be the one -// held, and it would not have written the fill's change. +// routing table's changes: the flush whose prefix write is held wrote those of +// the fill, so Stop must log the one of the extra prefix as not written. The +// flush timer must not fire. A flush it started before the fill would be the +// one held, and it would not have written the fill's change. func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) { t.Parallel() @@ -389,10 +382,8 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) { h := newPrefixHandler(store, newErrorLogger(&logs), routetable.New(routetable.Params{}), longerThanAnyTest) fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize) - // The peer's session goes down first, so the write held is the delete of - // its routes, and Stop must log that peer down too. stopWhileAWriteIsBlocked(t, store, h, h.done, - []*ristypes.RISMessage{sessionDown(), prefixAnnouncement(fill)}, + []*ristypes.RISMessage{prefixAnnouncement(fill)}, prefixAnnouncement([]string{extraPrefix})) store.mu.Lock() @@ -403,11 +394,10 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) { } logged := lastLogged(t, &logs) - if logged.Updates != prefixBatchSize+1 || logged.PeersDown != 1 || - logged.TableChanges != 1 { - t.Errorf("Stop logged %d updates, %d peer downs and %d table changes "+ - "not written, want %d, 1 and 1", logged.Updates, logged.PeersDown, - logged.TableChanges, prefixBatchSize+1) + if logged.Updates != prefixBatchSize+1 || logged.TableChanges != 1 { + t.Errorf("Stop logged %d updates and %d table changes not written, "+ + "want %d and 1", logged.Updates, logged.TableChanges, + prefixBatchSize+1) } } diff --git a/internal/routewatch/prefixhandler.go b/internal/routewatch/prefixhandler.go index 5fa550f..6472a39 100644 --- a/internal/routewatch/prefixhandler.go +++ b/internal/routewatch/prefixhandler.go @@ -2,10 +2,8 @@ package routewatch import ( "context" - "net" "net/netip" "slices" - "strings" "sync" "time" @@ -28,11 +26,9 @@ const ( prefixBatchSize = 25000 // prefixBatchMaxSize is the most prefix updates the batch holds while the - // flush goroutine writes the previous one: ten batches, about 60 MiB + // flush goroutine writes the previous one: ten batches, about 15 MiB // (README "Memory"). At this size HandleMessage waits until the flush - // goroutine takes the batch. On top of these, the handler holds at most - // prefixBatchSize updates, about 6 MiB, that a flush kept for the peers - // whose route delete failed (held). + // goroutine takes the batch. prefixBatchMaxSize = 250000 // prefixBatchTimeout is the maximum time to wait before flushing a batch @@ -48,47 +44,31 @@ 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 - - // IP version constants - ipv4Version = 4 - ipv6Version = 6 ) -// PrefixHandler tracks BGP prefixes and maintains a live routing table in the -// database. Routes are added on announcement and deleted on withdrawal or on an -// announcement whose AS path is empty or holds AS 0 anywhere, and all routes of -// a peer are deleted when its BGP session goes down. +// PrefixHandler keeps the routing table in memory current from the feed. It +// applies each update to the table as it handles it. An announcement gives +// the peer a route to the prefix. A withdrawal removes the peer's route, as +// does an announcement whose AS path is empty or holds AS 0 anywhere. A peer +// whose BGP session goes down leaves every prefix. // -// It applies the same updates to the routing table in memory as it handles -// them, and writes the table's changes to the database with each flush. +// 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 { db database.Store logger *logger.Logger metrics *metrics.Tracker table *routetable.Table - // Batching. mu guards batch, held, peersDown, writing, deleting 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 prefixBatchMaxSize. + // 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 + // prefixBatchMaxSize. mu sync.Mutex - batch []prefixUpdate - // held holds the updates that the peers whose route delete failed in the - // last flush sent after their session went down, for the next flush to - // write after it retries the delete. They are not in batch, so they do - // not count toward a full batch: a delete that keeps failing is retried - // at the pace of the flush timer. - held []prefixUpdate - // peersDown holds, each once, the peers whose BGP session went down - // since the batch was taken, and those whose route delete failed in the - // last flush. - peersDown []string + batch []seenPrefix // writing is how many updates the flush goroutine took and has not // finished writing. writing int - // deleting is how many peers from peersDown the flush goroutine took and - // has not yet deleted the routes of. - deleting int // writingTableChanges is how many changes the flush goroutine took from // the routing table and has not finished writing. writingTableChanges int @@ -102,6 +82,15 @@ type PrefixHandler struct { done chan struct{} // closed when the flush goroutine returns } +// seenPrefix is what the batch keeps of an update: the prefix it names and its +// time. A flush needs only these to store each new prefix's first-seen time. +type seenPrefix struct { + prefix string + timestamp time.Time +} + +// prefixUpdate is one prefix of an update message, as applyToTable applies it +// to the routing table. type prefixUpdate struct { prefix string originASN int @@ -144,7 +133,7 @@ func newPrefixHandler( db: db, logger: logger, table: table, - batch: make([]prefixUpdate, 0, prefixBatchSize), + batch: make([]seenPrefix, 0, prefixBatchSize), batchFullCh: make(chan struct{}, 1), stopCh: make(chan context.Context, 1), done: make(chan struct{}), @@ -181,7 +170,7 @@ func (h *PrefixHandler) QueueCapacity() int { func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { if msg.Type == ristypes.MessageTypeState { if msg.State == ristypes.StateDown { - h.peerSessionDown(msg.Peer) + h.table.PeerDown(msg.Peer) } return @@ -206,12 +195,11 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { h.batchTaken.Wait() } - // Withdrawals are queued, and applied to the routing table, before - // announcements, because the batch keeps each peer's last update for a - // prefix: a message that lists a prefix as both withdrawn and announced - // keeps the route (RFC 4271 section 4.3). A withdrawal names only the - // prefix and peer: the message's AS path, and so its origin AS, belongs - // to the announced prefixes. + // Withdrawals are applied to the routing table before announcements, so + // a message that lists a prefix as both withdrawn and announced keeps the + // route (RFC 4271 section 4.3). A withdrawal names only the prefix and + // peer: the message's AS path, and so its origin AS, belongs to the + // announced prefixes. for _, prefix := range msg.Withdrawals { update := prefixUpdate{ prefix: canonicalPrefix(prefix), @@ -219,7 +207,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { messageType: "withdrawal", timestamp: timestamp, } - h.batch = append(h.batch, update) + h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp}) h.applyToTable(update) // Record withdrawal in metrics @@ -239,7 +227,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { path: path, endsInSet: len(msg.Path.Set) > 0, } - h.batch = append(h.batch, update) + h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp}) h.applyToTable(update) // Record announcement in metrics @@ -261,14 +249,12 @@ func (h *PrefixHandler) HandleMessage(msg *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 the updates and peer downs -// the handler holds, and at most maxTableChangesPerWrite of the routing table's -// changes. Once ctx has ended the flush goroutine skips that last flush, and a -// write still running is lost when the process exits. A peer whose route delete -// fails in the last flush is not retried either, nor is a failed write of the -// table's changes. Stop logs how many updates are not written, how many peer -// downs have not had their routes deleted, and how many of the table's changes -// are not written. +// and waits for it until ctx ends. That flush writes at most +// maxTableChangesPerWrite 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 @@ -278,176 +264,96 @@ func (h *PrefixHandler) Stop(ctx context.Context) { } h.mu.Lock() - unwritten := h.writing + len(h.batch) + len(h.held) - peersDown := h.deleting + len(h.peersDown) + unwritten := h.writing + len(h.batch) tableChanges := h.writingTableChanges + h.table.ChangeCount() h.mu.Unlock() - if unwritten == 0 && peersDown == 0 && tableChanges == 0 { + 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, "peers_down", peersDown, - "table_changes", tableChanges) + "its updates", "updates", unwritten, "table_changes", tableChanges) return } h.logger.Error("The prefix handler stopped before it wrote everything", - "updates", unwritten, "peers_down", peersDown, - "table_changes", tableChanges) + "updates", unwritten, "table_changes", tableChanges) } -// peerSessionDown notes that the peer's BGP session went down, so that the next -// flush deletes all of the peer's live routes before it writes the batch. The -// peer's updates still in the batch or held came before the down, which -// replaces them, so they are dropped; updates the peer sends later stay in the -// batch and are written after the delete. A peer already in peersDown is not -// added again: one delete removes all of its routes. In the routing table in -// memory the down takes effect at once. -func (h *PrefixHandler) peerSessionDown(peer string) { - h.mu.Lock() - defer h.mu.Unlock() - - h.table.PeerDown(peer) - - isPeers := func(update prefixUpdate) bool { return update.peer == peer } - h.batch = slices.DeleteFunc(h.batch, isPeers) - h.held = slices.DeleteFunc(h.held, isPeers) - - if !slices.Contains(h.peersDown, peer) { - h.peersDown = append(h.peersDown, peer) - } -} - -// takeBatch returns the pending updates, the held ones first, and the peers in -// peersDown, and leaves an empty batch, no held updates and no peers in their +// takeBatch returns the pending updates and leaves an empty batch in their // place for HandleMessage to fill. The updates count in writing until -// flushBatch has written them, and the peers in deleting until their routes -// are deleted. -func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) { +// flushBatch has written them. +func (h *PrefixHandler) takeBatch() []seenPrefix { h.mu.Lock() defer h.mu.Unlock() - if len(h.held) == 0 && len(h.batch) == 0 && len(h.peersDown) == 0 { - return nil, nil - } - - batch, peersDown := h.batch, h.peersDown - if len(h.held) > 0 { - batch = slices.Concat(h.held, h.batch) + if len(h.batch) == 0 { + return nil } + batch := h.batch h.writing = len(batch) - h.deleting = len(peersDown) - h.held = nil - h.batch = make([]prefixUpdate, 0, prefixBatchSize) - h.peersDown = nil + h.batch = make([]seenPrefix, 0, prefixBatchSize) h.batchTaken.Broadcast() - return batch, peersDown + return batch } // flushBatch writes the routing table's changes, then takes the pending batch -// and writes it to the database, without holding the mutex during the writes. +// and stores the prefixes it names, without holding the mutex during the +// writes. func (h *PrefixHandler) flushBatch() { h.writeTableChanges() - batch, peersDown := h.takeBatch() - if len(batch) == 0 && len(peersDown) == 0 { + batch := h.takeBatch() + if len(batch) == 0 { return } startTime := time.Now() - batchSize := len(batch) - - // The routes of a peer whose session went down are deleted first: the - // batch holds only the updates the peer sent after the down. A peer whose - // delete failed is kept, with those updates, for the next flush to retry. - failedPeers := h.deleteLiveRoutesOfPeers(peersDown) - if len(failedPeers) > 0 { - batch = h.keepForNextFlush(batch, failedPeers) - } - - // For each prefix and peer, keep only the last update the feed delivered. - // Updates from different peers for the same prefix are all kept. Arrival - // order decides, not the timestamp: timestamps are whole seconds, so - // updates within one second carry the same one. - type prefixAndPeer struct{ prefix, peer string } - - lastUpdates := make(map[prefixAndPeer]prefixUpdate) // Each prefix the batch names, announced or withdrawn, with the earliest - // timestamp among all of its updates, not only each peer's last one: the - // prefix tables store only first_seen, written when the prefix is new and - // never changed after. + // timestamp among its updates: the prefix tables store only first_seen, + // written when the prefix is new and never changed after. prefixesToUpdate := make(map[string]time.Time) for _, update := range batch { - lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update - seen, exists := prefixesToUpdate[update.prefix] if !exists || update.timestamp.Before(seen) { prefixesToUpdate[update.prefix] = update.timestamp } } - // Collect routes to upsert and delete - var ( - routesToUpsert []*database.LiveRoute - routesToDelete []database.LiveRouteDeletion - ) - - for _, update := range lastUpdates { - if update.announcesRoute() { - // Create live route for batch upsert - route := h.createLiveRoute(update) - if route != nil { - routesToUpsert = append(routesToUpsert, route) - } - } else { - // Parse CIDR to get IP version - _, ipVersion, err := parseCIDR(update.prefix) - if err != nil { - h.logger.Error("Failed to parse CIDR for route deletion", - "prefix", update.prefix, "error", err) - - continue - } - - // Create deletion record for batch delete - routesToDelete = append(routesToDelete, database.LiveRouteDeletion{ - Prefix: update.prefix, - PeerIP: update.peer, - IPVersion: ipVersion, - }) - } + err := h.db.UpdatePrefixesBatch(prefixesToUpdate) + if err != nil { + h.logger.Error("Failed to update prefix batch", + "error", err, "count", len(prefixesToUpdate)) } - successCount := h.writeBatch(routesToUpsert, routesToDelete, prefixesToUpdate) - h.mu.Lock() h.writing = 0 h.mu.Unlock() - elapsed := time.Since(startTime) h.logger.Debug("Flushed prefix batch", - "batch_size", batchSize, - "peers_down", len(peersDown), + "batch_size", len(batch), "unique_prefixes", len(prefixesToUpdate), - "success", successCount, - "duration_ms", elapsed.Milliseconds(), + "duration_ms", time.Since(startTime).Milliseconds(), ) } // applyToTable applies update to the routing table in memory: as an // announcement when it gives the peer a route, otherwise as a withdrawal. A -// prefix that does not parse is left out; flushBatch logs it. +// prefix that does not parse is logged and left out. Each announcement counts +// in the IPv4 or IPv6 route update rate. func (h *PrefixHandler) applyToTable(update prefixUpdate) { prefix, err := netip.ParsePrefix(update.prefix) if err != nil { + h.logger.Error("Failed to parse prefix", + "prefix", update.prefix, "error", err) + return } @@ -457,6 +363,14 @@ func (h *PrefixHandler) applyToTable(update prefixUpdate) { return } + if h.metrics != nil { + if prefix.Addr().Is4() { + h.metrics.RecordIPv4Update() + } else { + h.metrics.RecordIPv6Update() + } + } + h.table.Announce(prefix, update.originASN, routetable.Route{ Peer: update.peer, ASPath: update.path, @@ -495,147 +409,11 @@ func (h *PrefixHandler) writeTableChanges() { } } -// deleteLiveRoutesOfPeers deletes all live routes of each of peers, the peers -// whose BGP session went down, and returns those whose delete failed. Most -// downs come from peers that have no routes, so only a delete that removed -// routes is logged. A peer whose delete succeeded no longer counts in deleting; -// a failed one counts until keepForNextFlush puts it back in peersDown. -func (h *PrefixHandler) deleteLiveRoutesOfPeers(peers []string) []string { - var failed []string - - for _, peer := range peers { - deleted, err := h.db.DeleteLiveRoutesOfPeer(peer) - if err != nil { - h.logger.Error( - "Failed to delete the live routes of a peer whose session "+ - "went down; the next flush retries", - "peer", peer, "routes_deleted", deleted, "error", err) - - failed = append(failed, peer) - - continue - } - - h.mu.Lock() - h.deleting-- - h.mu.Unlock() - - if deleted > 0 { - h.logger.Info( - "Deleted the live routes of a peer whose session went down", - "peer", peer, "routes", deleted) - } - } - - return failed -} - -// keepForNextFlush puts the peers whose route delete failed back in peersDown, -// and their updates from batch in held, so that the next flush retries each -// delete before it writes the peer's updates; written now, they would be -// deleted by the retry. The updates of a peer whose session went down again -// since the batch was taken came before that down, so they are dropped, as -// peerSessionDown drops them, and the peer is already in peersDown. It returns -// the updates of the other peers, for this flush to write. -// -// While a delete keeps failing, the held updates would grow with every flush, -// so only the last prefixBatchSize of them are kept and the older ones are -// dropped. This never leaves in place a route the peer withdrew: a prefix with -// a kept update keeps its last one, and the retry deletes all of the peer's -// routes, so a prefix whose updates were all dropped has no route from the peer -// until the peer announces it again. -func (h *PrefixHandler) keepForNextFlush( - batch []prefixUpdate, failedPeers []string, -) []prefixUpdate { - var kept, toWrite []prefixUpdate - - for _, update := range batch { - if slices.Contains(failedPeers, update.peer) { - kept = append(kept, update) - } else { - toWrite = append(toWrite, update) - } - } - - h.mu.Lock() - defer h.mu.Unlock() - - kept = slices.DeleteFunc(kept, func(update prefixUpdate) bool { - return slices.Contains(h.peersDown, update.peer) - }) - - if len(kept) > prefixBatchSize { - h.logger.Error( - "Dropped the oldest updates of peers whose route delete failed", - "peers", failedPeers, "dropped", len(kept)-prefixBatchSize) - - // A copy, so that the dropped updates are freed. - kept = slices.Clone(kept[len(kept)-prefixBatchSize:]) - } - - // takeBatch left held empty, and only this flush fills it. The kept - // updates now count in held, not in writing, and the failed peers in - // peersDown, not in deleting. - h.held = kept - h.writing = len(toWrite) - h.deleting = 0 - - for _, peer := range failedPeers { - if !slices.Contains(h.peersDown, peer) { - h.peersDown = append(h.peersDown, peer) - } - } - - return toWrite -} - -// writeBatch writes the collected route upserts, route deletions and prefix -// updates to the database and returns how many routes it wrote. -func (h *PrefixHandler) writeBatch( - routesToUpsert []*database.LiveRoute, - routesToDelete []database.LiveRouteDeletion, - prefixesToUpdate map[string]time.Time, -) int { - successCount := 0 - - if len(routesToUpsert) > 0 { - err := h.db.UpsertLiveRouteBatch(routesToUpsert) - if err != nil { - h.logger.Error("Failed to upsert route batch", - "error", err, "count", len(routesToUpsert)) - } else { - successCount += len(routesToUpsert) - } - } - - if len(routesToDelete) > 0 { - err := h.db.DeleteLiveRouteBatch(routesToDelete) - if err != nil { - h.logger.Error("Failed to delete route batch", - "error", err, "count", len(routesToDelete)) - } else { - successCount += len(routesToDelete) - } - } - - // Update prefix tables - if len(prefixesToUpdate) > 0 { - err := h.db.UpdatePrefixesBatch(prefixesToUpdate) - if err != nil { - h.logger.Error("Failed to update prefix batch", - "error", err, "count", len(prefixesToUpdate)) - } - } - - return successCount -} - // canonicalPrefix returns prefix in the text form net/netip prints, the form -// the IP lookup builds when it looks a prefix up. The feed sends IPv6 +// the prefix tables store and the IP lookup reads them in. The feed sends IPv6 // announcements compressed ("2001:db8::/32") but IPv6 withdrawals uncompressed -// ("2001:db8:0:0:0:0:0:0/32"); stored as received, a withdrawal would not match -// the route its announcement stored. A prefix that does not parse is returned -// unchanged, and the batch flush reports it. +// ("2001:db8:0:0:0:0:0:0/32"). A prefix that does not parse is returned +// unchanged, and applyToTable logs it. func canonicalPrefix(prefix string) string { p, err := netip.ParsePrefix(prefix) if err != nil { @@ -644,55 +422,3 @@ func canonicalPrefix(prefix string) string { return p.Masked().String() } - -// parseCIDR returns the mask length and the IP version of a prefix string -func parseCIDR(prefix string) (int, int, error) { - _, ipNet, err := net.ParseCIDR(prefix) - if err != nil { - return 0, 0, err - } - - ones, _ := ipNet.Mask.Size() - if strings.Contains(prefix, ":") { - return ones, ipv6Version, nil - } - - return ones, ipv4Version, nil -} - -// createLiveRoute creates a LiveRoute from a prefix update -func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute { - // Parse CIDR to get mask length - maskLength, ipVersion, err := parseCIDR(update.prefix) - if err != nil { - h.logger.Error("Failed to parse CIDR", - "prefix", update.prefix, - "error", err, - ) - - return nil - } - - // Track route update metrics - if h.metrics != nil { - if ipVersion == ipv4Version { - h.metrics.RecordIPv4Update() - } else { - h.metrics.RecordIPv6Update() - } - } - - // Create live route record - liveRoute := &database.LiveRoute{ - Prefix: update.prefix, - MaskLength: maskLength, - IPVersion: ipVersion, - OriginASN: update.originASN, - PeerIP: update.peer, - ASPath: update.path, - NextHop: update.peer, // Using peer as next hop - LastUpdated: update.timestamp, - } - - return liveRoute -} diff --git a/internal/routewatch/prefixhandler_internal_test.go b/internal/routewatch/prefixhandler_internal_test.go index 2e95267..c7401d6 100644 --- a/internal/routewatch/prefixhandler_internal_test.go +++ b/internal/routewatch/prefixhandler_internal_test.go @@ -3,12 +3,7 @@ package routewatch import ( "bytes" "context" - "errors" - "fmt" "net/netip" - "slices" - "strings" - "sync" "testing" "time" @@ -32,14 +27,18 @@ const ( const secondUpdateAfter = 5 * time.Second // TestPrefixHandlerStoresPrefixesTheIPLookupFinds runs announcements and -// withdrawals through the prefix handler into a real database and looks the -// addresses up. The prefixes are written the way the feed sends them: IPv6 -// announcements compressed, IPv6 withdrawals uncompressed. The withdrawal must -// remove the route the announcement stored, and the IP lookup must find the -// stored prefix. +// withdrawals through the prefix handler into a real database. The prefixes +// are written the way the feed sends them: IPv6 announcements compressed, IPv6 +// withdrawals uncompressed. The IP lookup must find each announced prefix in +// the routing table, and its first-seen time in the prefix tables. The +// withdrawal must remove the peer's route and store no second form of the +// prefix. func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) { t.Parallel() + // The two prefixes the announcement names. + const wantPrefixes = 2 + db := newTestDatabase(t) h := prefixHandlerWithoutTimer(db) @@ -59,14 +58,18 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) { "2001:db8:1::1": testV6Prefix, "192.0.2.1": testV4Prefix, } { - info, err := db.GetIPInfo(ip) - if err != nil { - t.Fatalf("GetIPInfo(%s) after announcement: %v", ip, err) + summary, found := h.table.Lookup(netip.MustParseAddr(ip)) + if !found || summary.Prefix.String() != want || + summary.OriginASN != testASNB { + t.Errorf("Lookup(%s) after announcement = %+v, %t; want %s AS%d", + ip, summary, found, want, testASNB) } - if info.Netblock != want || info.ASN != testASNB { - t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d", - ip, info.Netblock, info.ASN, want, testASNB) + firstSeen, err := db.GetPrefixFirstSeen(t.Context(), + netip.MustParsePrefix(want)) + if err != nil || !firstSeen.Equal(ts) { + t.Errorf("GetPrefixFirstSeen(%s) = %v, %v; want %v", + want, firstSeen, err, ts) } } @@ -78,20 +81,28 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) { h.flushBatch() for _, ip := range []string{"2001:db8:1::1", "192.0.2.1"} { - info, err := db.GetIPInfo(ip) - if !errors.Is(err, database.ErrNoRoute) { - t.Errorf("GetIPInfo(%s) after withdrawal = %+v, %v; want ErrNoRoute", - ip, info, err) + summary, found := h.table.Lookup(netip.MustParseAddr(ip)) + if found { + t.Errorf("Lookup(%s) after withdrawal = %+v, want none", ip, summary) } } + + stats, err := db.GetStatsContext(t.Context()) + if err != nil { + t.Fatalf("GetStatsContext: %v", err) + } + + if stats.Prefixes != wantPrefixes { + t.Errorf("the prefix tables hold %d prefixes after the withdrawal, "+ + "want %d", stats.Prefixes, wantPrefixes) + } } // TestPrefixHandlerStoresANewPrefixFirstSeenAtItsEarliestUpdate announces a // prefix the database does not hold yet twice in one batch, the second time 5 // seconds after the first, through the prefix handler into a real database. The // prefix is stored first seen at the time of the first announcement, whether -// the two come from two peers or from one, and the IP lookup answers with that -// time. +// the two come from two peers or from one. func TestPrefixHandlerStoresANewPrefixFirstSeenAtItsEarliestUpdate( t *testing.T, ) { @@ -116,336 +127,20 @@ func TestPrefixHandlerStoresANewPrefixFirstSeenAtItsEarliestUpdate( second.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter) db := handleBatches(t, [][]*ristypes.RISMessage{{first, second}}) - info, err := db.GetIPInfo("192.0.2.1") + firstSeen, err := db.GetPrefixFirstSeen(t.Context(), + netip.MustParsePrefix(testV4Prefix)) if err != nil { - t.Fatalf("GetIPInfo(192.0.2.1): %v", err) + t.Fatalf("GetPrefixFirstSeen(%s): %v", testV4Prefix, err) } - if !info.FirstSeen.Equal(first.ParsedTimestamp) { + if !firstSeen.Equal(first.ParsedTimestamp) { t.Errorf("first_seen of %s = %s, want %s", testV4Prefix, - info.FirstSeen, first.ParsedTimestamp) + firstSeen, first.ParsedTimestamp) } }) } } -// TestPrefixHandlerAppliesEachPeersLastUpdateInABatch runs updates for one -// prefix through the prefix handler into a real database. All updates carry the -// same timestamp, as updates within one second of the feed do. Within a batch, -// the last update each peer sent for the prefix decides whether that peer has a -// live route for it, and updates from different peers are all applied. The -// in-memory route count and prefix distribution must match the route table. -func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) { - t.Parallel() - - const ( - prefix = "198.51.100.0/24" - prefixMaskLength = 24 - otherPeerIP = "2001:db8:ffff::2" - ) - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - announce := func(peer string) *ristypes.RISMessage { - return &ristypes.RISMessage{ - Peer: peer, - Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, - ParsedTimestamp: ts, - Announcements: []ristypes.RISAnnouncement{ - {NextHop: peer, Prefixes: []string{prefix}}, - }, - } - } - withdraw := func(peer string) *ristypes.RISMessage { - return &ristypes.RISMessage{Peer: peer, ParsedTimestamp: ts, - Withdrawals: []string{prefix}} - } - announceAndWithdraw := func(peer string) *ristypes.RISMessage { - msg := announce(peer) - msg.Withdrawals = []string{prefix} - - return msg - } - - tests := []struct { - name string - // Each inner list is handled and then flushed as one batch. - batches [][]*ristypes.RISMessage - // The peers left with a live route for the prefix, by peer IP. - wantPeers []string - }{ - { - name: "two peers announce the prefix", - batches: [][]*ristypes.RISMessage{ - {announce(testPeerIP), announce(otherPeerIP)}, - }, - wantPeers: []string{testPeerIP, otherPeerIP}, - }, - { - name: "two peers withdraw the prefix", - batches: [][]*ristypes.RISMessage{ - {announce(testPeerIP)}, - {announce(otherPeerIP)}, - {withdraw(testPeerIP), withdraw(otherPeerIP)}, - }, - }, - { - name: "a peer announces and then withdraws the prefix", - batches: [][]*ristypes.RISMessage{{announce(testPeerIP), withdraw(testPeerIP)}}, - }, - { - name: "a peer withdraws and then re-announces the prefix", - batches: [][]*ristypes.RISMessage{ - {announce(testPeerIP)}, - {withdraw(testPeerIP), announce(testPeerIP)}, - }, - wantPeers: []string{testPeerIP}, - }, - { - // RFC 4271 section 4.3: the announcement counts, not the withdrawal. - name: "one message announces and withdraws the prefix", - batches: [][]*ristypes.RISMessage{ - {announce(testPeerIP)}, - {announceAndWithdraw(testPeerIP)}, - }, - wantPeers: []string{testPeerIP}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - db := handleBatches(t, tt.batches) - checkLivePeers(t, db, prefix, prefixMaskLength, tt.wantPeers) - }) - } -} - -// TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer runs updates from one peer -// through the prefix handler into a real database, flushing each message as its -// own batch, once with IPv4 prefixes and once with IPv6 prefixes. A peer has at -// most one route for a prefix: a later announcement replaces it whatever its -// origin AS, and a withdrawal removes it whatever AS the message's AS path ends -// in. The in-memory route count and prefix distribution must match the route -// table. -func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) { - t.Parallel() - - families := []struct { - ipVersion int - prefix, otherPrefix string - // The mask length of both prefixes. - maskLength int - }{ - { - ipVersion: ipv4Version, maskLength: 24, - prefix: "198.51.100.0/24", otherPrefix: "203.0.113.0/24", - }, - { - ipVersion: ipv6Version, maskLength: 48, - prefix: testV6Prefix, otherPrefix: "2001:db8:2::/48", - }, - } - - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - announce := func(path ristypes.ASPath, announced string) *ristypes.RISMessage { - return &ristypes.RISMessage{ - Peer: testPeerIP, - Path: path, - ParsedTimestamp: ts, - Announcements: []ristypes.RISAnnouncement{ - {NextHop: testPeerIP, Prefixes: []string{announced}}, - }, - } - } - - for _, family := range families { - prefix, otherPrefix := family.prefix, family.otherPrefix - - // One message that announces otherPrefix with origin AS testASNB and - // withdraws prefix. - announceOtherAndWithdraw := announce( - ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, otherPrefix) - announceOtherAndWithdraw.Withdrawals = []string{prefix} - - tests := []struct { - name string - // Each message is handled and then flushed as its own batch. - batches [][]*ristypes.RISMessage - // The origin AS of the peer's live route for each prefix. A prefix - // left out must have no live route. - wantOrigins map[string]int - }{ - { - name: "the peer announces the prefix with a new origin AS", - batches: [][]*ristypes.RISMessage{ - {announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)}, - {announce(ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, prefix)}, - }, - wantOrigins: map[string]int{prefix: testASNB}, - }, - { - name: "one message announces a prefix with another origin AS " + - "and withdraws the prefix", - batches: [][]*ristypes.RISMessage{ - {announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)}, - {announceOtherAndWithdraw}, - }, - wantOrigins: map[string]int{otherPrefix: testASNB}, - }, - } - - for _, tt := range tests { - name := fmt.Sprintf("IPv%d %s", family.ipVersion, tt.name) - t.Run(name, func(t *testing.T) { - t.Parallel() - - db := handleBatches(t, tt.batches) - checkLiveOrigins(t, db, tt.wantOrigins, prefix, otherPrefix) - - wantDistribution := []database.PrefixDistribution{ - {MaskLength: family.maskLength, Count: len(tt.wantOrigins)}, - } - assertCounts(t, db, family.ipVersion, len(tt.wantOrigins), - wantDistribution) - }) - } - } -} - -// TestPrefixHandlerStoresNoOriginForAPathEndingInASet announces a prefix with -// AS paths that end in an AS set through the prefix handler into a real -// database. Any member of a set of several may be the origin, so the route is -// stored with origin 0 (none); a set of one member counts as that AS. The -// stored AS path lists the set's members after the rest of the path. -func TestPrefixHandlerStoresNoOriginForAPathEndingInASet(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - path []any - wantOrigin int - wantPath []int - }{ - { - name: "a set of several members", - path: []any{testASNA, testASNB, []int{testASNC, testASND}}, - wantPath: []int{testASNA, testASNB, testASNC, testASND}, - }, - { - name: "a set of one member", - path: []any{testASNA, testASNB, []int{testASNC}}, - wantOrigin: testASNC, - wantPath: []int{testASNA, testASNB, testASNC}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - msg := peerAnnouncement(testPeerIP, testV4Prefix) - msg.Path = decodePath(t, tt.path...) - db := handleBatches(t, [][]*ristypes.RISMessage{{msg}}) - - routes, _, err := db.GetPrefixDetailsContext( - context.Background(), testV4Prefix) - if err != nil { - t.Fatalf("GetPrefixDetailsContext(%s): %v", testV4Prefix, err) - } - - if len(routes) != 1 || routes[0].OriginASN != tt.wantOrigin || - !slices.Equal(routes[0].ASPath, tt.wantPath) { - t.Errorf("live routes for %s = %+v, "+ - "want one with origin AS%d and AS path %v", - testV4Prefix, routes, tt.wantOrigin, tt.wantPath) - } - }) - } -} - -// TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath announces a -// prefix with an empty AS path, which gives no origin and does not end in an -// AS set, so no live route is stored. -func TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath(t *testing.T) { - t.Parallel() - - msg := peerAnnouncement(testPeerIP, testV4Prefix) - msg.Path = ristypes.ASPath{} - db := handleBatches(t, [][]*ristypes.RISMessage{{msg}}) - - assertPeers(t, db, map[string][]string{testV4Prefix: nil}) -} - -// TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin runs, through -// the prefix handler into a real database, a peer's announcement of a prefix -// with origin AS testASNB and then the peer's announcement of the prefix with -// an AS path that gives no origin: one whose last AS is 0, an empty one, or one -// that holds AS 0 before its end, which decodes as an empty path. As any later -// announcement from the peer does, the second replaces the peer's route, here -// with none, whether it comes in a later batch or follows a withdrawal in one -// batch. The in-memory route count and prefix distribution must match the -// route table. -func TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin( - t *testing.T, -) { - t.Parallel() - - const prefixMaskLength = 24 - - noOrigin := func(path ristypes.ASPath) *ristypes.RISMessage { - msg := peerAnnouncement(testPeerIP, testV4Prefix) - msg.Path = path - - return msg - } - endsInZero := ristypes.ASPath{Sequence: []int{testASNA, 0}} - withdrawal := &ristypes.RISMessage{ - Type: ristypes.MessageTypeUpdate, - Peer: testPeerIP, - ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), - Withdrawals: []string{testV4Prefix}, - } - - tests := []struct { - name string - // Handled and flushed as one batch after the announcement with origin - // AS testASNB. - batch []*ristypes.RISMessage - }{ - { - name: "a path whose last AS is 0", - batch: []*ristypes.RISMessage{noOrigin(endsInZero)}, - }, - { - name: "an empty path", - batch: []*ristypes.RISMessage{noOrigin(ristypes.ASPath{})}, - }, - { - name: "a path with AS 0 before its end", - batch: []*ristypes.RISMessage{ - noOrigin(decodePath(t, testASNA, 0, testASNB)), - }, - }, - { - name: "a withdrawal and then a path whose last AS is 0", - batch: []*ristypes.RISMessage{withdrawal, noOrigin(endsInZero)}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - db := handleBatches(t, [][]*ristypes.RISMessage{ - {peerAnnouncement(testPeerIP, testV4Prefix)}, - tt.batch, - }) - checkLivePeers(t, db, testV4Prefix, prefixMaskLength, nil) - }) - } -} - // TestPrefixHandlerDoesNotStoreAPrefixThatDoesNotParse runs an announcement of // "not-a-prefix" and a withdrawal of "also/bad" through the prefix handler into // a real database. Neither is stored in the prefix tables: the prefix count in @@ -522,461 +217,23 @@ func TestStateMessagesGoToThePrefixHandlerNotThePeerHandler(t *testing.T) { } } -// TestPrefixHandlerDeletesAPeersRoutesWhenItsSessionGoesDown runs -// announcements from two peers through the prefix handler into a real -// database, then a batch in which one of the peers announces a prefix and its -// session goes down. The down deletes every live route of that peer, IPv4 and -// IPv6, the one announced just before it included, and keeps the other peer's -// routes for the same prefixes. An announcement the peer sends after the down, -// in the same batch, is kept. The in-memory route counts and prefix -// distribution must match the route tables. -func TestPrefixHandlerDeletesAPeersRoutesWhenItsSessionGoesDown(t *testing.T) { - t.Parallel() - - const ( - otherPeerIP = "2001:db8:ffff::2" - // Both peers announce the shared prefixes, only testPeerIP the others. - sharedV4 = "198.51.100.0/24" - sharedV6 = "2001:db8:1::/48" - onlyV4 = "203.0.113.0/24" - onlyV6 = "2001:db8:2::/48" - beforeDown = "192.0.2.0/24" - afterDown = "2001:db8:3::/48" - // The mask lengths of the IPv4 and of the IPv6 prefixes. - maskLengthV4 = 24 - maskLengthV6 = 48 - ) - - tests := []struct { - name string - // Handled and flushed as one batch after both peers' announcements. - batch []*ristypes.RISMessage - // Whether testPeerIP is left with a live route for afterDown. - wantAfterDown bool - }{ - { - name: "the down ends the batch", - batch: []*ristypes.RISMessage{ - peerAnnouncement(testPeerIP, beforeDown), - sessionDown(), - }, - }, - { - name: "the peer announces a prefix after the down", - batch: []*ristypes.RISMessage{ - peerAnnouncement(testPeerIP, beforeDown), - sessionDown(), - peerAnnouncement(testPeerIP, afterDown), - }, - wantAfterDown: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - db := handleBatches(t, [][]*ristypes.RISMessage{ - { - peerAnnouncement(testPeerIP, - sharedV4, sharedV6, onlyV4, onlyV6), - peerAnnouncement(otherPeerIP, sharedV4, sharedV6), - }, - tt.batch, - }) - - // The peers left with a live route for each prefix. - wantPeers := map[string][]string{ - sharedV4: {otherPeerIP}, - sharedV6: {otherPeerIP}, - onlyV4: nil, - onlyV6: nil, - beforeDown: nil, - afterDown: nil, - } - wantRoutesV6 := 1 - - if tt.wantAfterDown { - wantPeers[afterDown] = []string{testPeerIP} - wantRoutesV6++ - } - - assertPeers(t, db, wantPeers) - assertCounts(t, db, ipv4Version, 1, []database.PrefixDistribution{ - {MaskLength: maskLengthV4, Count: 1}, - }) - assertCounts(t, db, ipv6Version, wantRoutesV6, - []database.PrefixDistribution{ - {MaskLength: maskLengthV6, Count: wantRoutesV6}, - }) - }) - } -} - -// TestPrefixHandlerKeepsWhatAPeerAnnouncesAfterTheFlushOfItsDown runs, through -// the prefix handler into a real database, one flush after another: a peer's -// announcement, its session going down, a new announcement from the peer, and -// another peer's announcement. The down deletes the peer's routes on the flush -// that handles it and on no later flush, so the route the peer announced after -// it is kept. -func TestPrefixHandlerKeepsWhatAPeerAnnouncesAfterTheFlushOfItsDown(t *testing.T) { - t.Parallel() - - const ( - otherPeerIP = "2001:db8:ffff::2" - beforeDown = "192.0.2.0/24" - afterDown = "198.51.100.0/24" - otherPeers = "203.0.113.0/24" - maskLength = 24 - ) - - // Each message is handled and then flushed as its own batch. - db := handleBatches(t, [][]*ristypes.RISMessage{ - {peerAnnouncement(testPeerIP, beforeDown)}, - {sessionDown()}, - {peerAnnouncement(testPeerIP, afterDown)}, - {peerAnnouncement(otherPeerIP, otherPeers)}, - }) - - assertPeers(t, db, map[string][]string{ - beforeDown: nil, - afterDown: {testPeerIP}, - otherPeers: {otherPeerIP}, - }) - assertCounts(t, db, ipv4Version, 2, []database.PrefixDistribution{ - {MaskLength: maskLength, Count: 2}, - }) -} - -// firstWriteHeldStore is a real database whose first live route batch write -// waits until the test closes release. writeStarted is closed when it starts. -type firstWriteHeldStore struct { - *database.Database - - writeStarted chan struct{} - release chan struct{} - once sync.Once -} - -// UpsertLiveRouteBatch holds the first call until release is closed, then -// writes routes to the database. -func (s *firstWriteHeldStore) UpsertLiveRouteBatch( - routes []*database.LiveRoute, -) error { - s.once.Do(func() { - close(s.writeStarted) - <-s.release - }) - - return s.Database.UpsertLiveRouteBatch(routes) -} - -// TestPrefixHandlerHandlesADownWhileAFlushWrites holds the write of a flush -// that stores a peer's route and meanwhile hands the prefix handler the peer's -// session going down, then a new announcement from the peer. The next flush -// must delete the route the held write stored and keep the one announced after -// the down. -func TestPrefixHandlerHandlesADownWhileAFlushWrites(t *testing.T) { - t.Parallel() - - const ( - beforeDown = "192.0.2.0/24" - afterDown = "198.51.100.0/24" - maskLength = 24 - ) - - db := newTestDatabase(t) - store := &firstWriteHeldStore{ - Database: db, - writeStarted: make(chan struct{}), - release: make(chan struct{}), - } - - h := prefixHandlerWithoutTimer(store) - h.HandleMessage(peerAnnouncement(testPeerIP, beforeDown)) - - flushed := make(chan struct{}) - - go func() { - h.flushBatch() - close(flushed) - }() - - select { - case <-store.writeStarted: - case <-time.After(handleMessageTimeout): - t.Fatal("the flush did not start its write") - } - - if !returnsInTime(func() { - h.HandleMessage(sessionDown()) - h.HandleMessage(peerAnnouncement(testPeerIP, afterDown)) - }) { - t.Fatal("HandleMessage did not return while a database write was held") - } - - close(store.release) - - select { - case <-flushed: - case <-time.After(handleMessageTimeout): - t.Fatal("the flush did not finish after its write was let go") - } - - h.flushBatch() - - assertPeers(t, db, map[string][]string{ - beforeDown: nil, - afterDown: {testPeerIP}, - }) - assertCounts(t, db, ipv4Version, 1, []database.PrefixDistribution{ - {MaskLength: maskLength, Count: 1}, - }) -} - -// errTestPeerDelete is the error a test store's failing delete of a peer's -// routes returns. -var errTestPeerDelete = errors.New("test: the delete of a peer's routes failed") - -// firstPeerDeleteFailsStore is a real database whose first delete of a peer's -// live routes fails, deleting nothing. deletes counts the deletes asked for. -type firstPeerDeleteFailsStore struct { - *database.Database - - // whileFailing, if not nil, runs during the delete that fails. - whileFailing func() - deletes int -} - -// DeleteLiveRoutesOfPeer fails the first call and deletes the peer's routes on -// every later one. -func (s *firstPeerDeleteFailsStore) DeleteLiveRoutesOfPeer( - peerIP string, -) (int, error) { - s.deletes++ - if s.deletes > 1 { - return s.Database.DeleteLiveRoutesOfPeer(peerIP) - } - - if s.whileFailing != nil { - s.whileFailing() - } - - return 0, errTestPeerDelete -} - -// TestPrefixHandlerRetriesAFailedDeleteOfAPeersRoutes runs, through the prefix -// handler into a database whose first delete of a peer's routes fails, one flush -// after another: a peer's announcement, then its session going down and a new -// announcement from the peer, then nothing. The failed delete must be retried -// on the next flush, and the announcement the peer sent after the down written -// only after that retry, so it is kept. In the other cases the peer's session -// goes down again, and the peer announces another prefix, while the delete -// fails or after the flush whose delete failed: the announcement sent between -// the two downs must not be written, and the next flush must delete the peer's -// routes once, not once for the retry and once for the down. -func TestPrefixHandlerRetriesAFailedDeleteOfAPeersRoutes(t *testing.T) { - t.Parallel() - - const ( - beforeDown = "192.0.2.0/24" - afterDown = "198.51.100.0/24" - afterSecondDown = "203.0.113.0/24" - maskLength = 24 - // The delete that fails and its retry, which is also the delete for - // a second down. - wantDeletes = 2 - ) - - tests := []struct { - name string - // Handled while the delete fails, if not nil. - whileFailing []*ristypes.RISMessage - // Handled after the flush whose delete fails, if not nil. - afterFailing []*ristypes.RISMessage - // The peers left with a live route for each prefix. - wantPeers map[string][]string - }{ - { - name: "the delete fails once", - wantPeers: map[string][]string{ - beforeDown: nil, afterDown: {testPeerIP}, afterSecondDown: nil, - }, - }, - { - name: "the session goes down again while the delete fails", - whileFailing: []*ristypes.RISMessage{ - sessionDown(), peerAnnouncement(testPeerIP, afterSecondDown), - }, - wantPeers: map[string][]string{ - beforeDown: nil, afterDown: nil, afterSecondDown: {testPeerIP}, - }, - }, - { - name: "the session goes down again after the delete failed", - afterFailing: []*ristypes.RISMessage{ - sessionDown(), peerAnnouncement(testPeerIP, afterSecondDown), - }, - wantPeers: map[string][]string{ - beforeDown: nil, afterDown: nil, afterSecondDown: {testPeerIP}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - db := newTestDatabase(t) - store := &firstPeerDeleteFailsStore{Database: db} - h := prefixHandlerWithoutTimer(store) - store.whileFailing = func() { - for _, msg := range tt.whileFailing { - h.HandleMessage(msg) - } - } - - h.HandleMessage(peerAnnouncement(testPeerIP, beforeDown)) - h.flushBatch() - h.HandleMessage(sessionDown()) - h.HandleMessage(peerAnnouncement(testPeerIP, afterDown)) - h.flushBatch() // the delete fails - - for _, msg := range tt.afterFailing { - h.HandleMessage(msg) - } - - h.flushBatch() - - if store.deletes != wantDeletes { - t.Errorf("the store was asked for %d deletes of the peer's "+ - "routes, want %d", store.deletes, wantDeletes) - } - - assertPeers(t, db, tt.wantPeers) - assertCounts(t, db, ipv4Version, 1, []database.PrefixDistribution{ - {MaskLength: maskLength, Count: 1}, - }) - }) - } -} - -// peerDeleteAlwaysFailsStore is a real database whose every delete of a peer's -// live routes fails, deleting nothing. -type peerDeleteAlwaysFailsStore struct { - *database.Database -} - -// DeleteLiveRoutesOfPeer fails. -func (peerDeleteAlwaysFailsStore) DeleteLiveRoutesOfPeer(string) (int, error) { - return 0, errTestPeerDelete -} - -// TestPrefixHandlerKeepsAtMostABatchOfUpdatesWhileADeleteKeepsFailing runs, -// through the prefix handler into a database whose every delete of a peer's -// routes fails, a peer's session going down and then three flushes. Before each -// flush the peer announces prefixBatchSize new prefixes and another peer -// announces one. After each flush the handler must hold only the peer's last -// prefixBatchSize updates, not every update since the down, and the other -// peer's route must be written. After the last flush, one more update must not -// wake the flush goroutine: held updates do not count toward a full batch. -func TestPrefixHandlerKeepsAtMostABatchOfUpdatesWhileADeleteKeepsFailing( - t *testing.T, -) { - t.Parallel() - - const otherPeerIP = "2001:db8:ffff::2" - - // One prefix the other peer announces before each flush. - otherPeersPrefixes := []string{testV4Prefix, testV6Prefix, "2001:db8:2::/48"} - - db := newTestDatabase(t) - h := prefixHandlerWithoutTimer(peerDeleteAlwaysFailsStore{Database: db}) - h.HandleMessage(sessionDown()) - - // The peer announces a /32 for each address from 10.0.0.0 on, each - // address once. - addr := netip.MustParseAddr("10.0.0.0") - - for flush, otherPeersPrefix := range otherPeersPrefixes { - prefixes := make([]string, prefixBatchSize) - for i := range prefixes { - prefixes[i] = netip.PrefixFrom(addr, addr.BitLen()).String() - addr = addr.Next() - } - - h.HandleMessage(peerAnnouncement(testPeerIP, prefixes...)) - h.HandleMessage(peerAnnouncement(otherPeerIP, otherPeersPrefix)) - h.flushBatch() - - kept := make([]string, 0, len(h.held)) - for _, update := range h.held { - kept = append(kept, update.prefix) - } - - if !slices.Equal(kept, prefixes) { - t.Fatalf("after flush %d the handler holds %d updates, not just "+ - "the peer's last %d", flush+1, len(kept), len(prefixes)) - } - - assertPeers(t, db, map[string][]string{otherPeersPrefix: {otherPeerIP}}) - } - - // A new channel, so that a wake-up in it comes from the update below. - h.batchFullCh = make(chan struct{}, 1) - h.HandleMessage(peerAnnouncement(otherPeerIP, testV4Prefix)) - - if len(h.batchFullCh) > 0 { - t.Error("one update after a flush that held a batch of updates " + - "woke the flush goroutine") - } -} - -// TestPrefixHandlerStopLogsAFailedDeleteWithoutTheDeadline stops a prefix -// handler, with no deadline, after a peer's session went down and the peer -// announced a prefix, into a database whose every delete of a peer's routes -// fails. Stop must log the peer down and the announcement as not written, and -// must not say that the stop deadline passed. -func TestPrefixHandlerStopLogsAFailedDeleteWithoutTheDeadline(t *testing.T) { - t.Parallel() - - var logs bytes.Buffer - - h := NewPrefixHandler(peerDeleteAlwaysFailsStore{Database: newTestDatabase(t)}, - newErrorLogger(&logs), routetable.New(routetable.Params{})) - h.HandleMessage(sessionDown()) - h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix)) - h.Stop(t.Context()) - - logged := lastLogged(t, &logs) - if strings.Contains(logged.Msg, "deadline") { - t.Errorf("Stop logged %q although its deadline had not passed", - logged.Msg) - } - - if logged.Updates != 1 || logged.PeersDown != 1 { - t.Errorf("Stop logged %d updates and %d peer downs not written, "+ - "want 1 and 1", logged.Updates, logged.PeersDown) - } -} - -// TestPrefixHandlerStopLogsNothingAfterADeleteThatSucceeds stops a prefix -// handler, with no deadline, after a peer's session went down, into a database -// whose delete of the peer's routes succeeds. Stop must log nothing: a peer -// whose routes are deleted no longer counts as a peer down left undone. -func TestPrefixHandlerStopLogsNothingAfterADeleteThatSucceeds(t *testing.T) { +// TestPrefixHandlerStopLogsNothingWhenItWroteEverything stops a prefix +// handler, with no deadline, after a peer announced a prefix and its session +// went down. Its last flush writes the routing table's changes and the prefix, +// so Stop must log nothing. +func TestPrefixHandlerStopLogsNothingWhenItWroteEverything(t *testing.T) { t.Parallel() var logs bytes.Buffer h := NewPrefixHandler(newTestDatabase(t), newErrorLogger(&logs), routetable.New(routetable.Params{})) + h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix)) h.HandleMessage(sessionDown()) h.Stop(t.Context()) if logs.Len() != 0 { - t.Errorf("Stop logged %q after the routes of the peer whose session "+ - "went down were deleted", logs.String()) + t.Errorf("Stop logged %q after it wrote everything", logs.String()) } } @@ -1055,130 +312,3 @@ func sessionDown() *ristypes.RISMessage { State: ristypes.StateDown, } } - -// checkLivePeers checks that exactly wantPeers have a live route for the IPv4 -// prefix, and that the in-memory route count and prefix distribution match -// the route table. -func checkLivePeers( - t *testing.T, db *database.Database, prefix string, maskLength int, - wantPeers []string, -) { - t.Helper() - - assertPeers(t, db, map[string][]string{prefix: wantPeers}) - - var wantDistribution []database.PrefixDistribution - if len(wantPeers) > 0 { - wantDistribution = []database.PrefixDistribution{ - {MaskLength: maskLength, Count: 1}, - } - } - - assertCounts(t, db, ipv4Version, len(wantPeers), wantDistribution) -} - -// assertPeers checks, for each prefix in wantPeers, that the peers with a live -// route for it are the ones listed, in peer IP order; nil means none. -func assertPeers( - t *testing.T, db *database.Database, wantPeers map[string][]string, -) { - t.Helper() - - for prefix, want := range wantPeers { - routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix) - if err != nil && !errors.Is(err, database.ErrNoRoute) { - t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err) - } - - got := make([]string, 0, len(routes)) - for _, route := range routes { - got = append(got, route.PeerIP) - } - - if !slices.Equal(got, want) { - t.Errorf("peers with a live route for %s = %v, want %v", - prefix, got, want) - } - } -} - -// checkLiveOrigins checks that each of prefixes has one live route, with the -// origin AS wantOrigins gives for it, or none when wantOrigins leaves it out. -func checkLiveOrigins( - t *testing.T, db *database.Database, wantOrigins map[string]int, - prefixes ...string, -) { - t.Helper() - - for _, prefix := range prefixes { - routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix) - if err != nil && !errors.Is(err, database.ErrNoRoute) { - t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err) - } - - gotOrigins := make([]int, 0, len(routes)) - for _, route := range routes { - gotOrigins = append(gotOrigins, route.OriginASN) - } - - var want []int - if origin, ok := wantOrigins[prefix]; ok { - want = []int{origin} - } - - if !slices.Equal(gotOrigins, want) { - t.Errorf("origin ASes of the live routes for %s = %v, want %v", - prefix, gotOrigins, want) - } - } -} - -// assertCounts checks that the live route count and prefix distribution of one -// IP version are wantRoutes and wantDistribution, both as held in memory and as -// read from the route table. -func assertCounts( - t *testing.T, db *database.Database, ipVersion, wantRoutes int, - wantDistribution []database.PrefixDistribution, -) { - t.Helper() - - ctx := context.Background() - - stats, err := db.GetStatsContext(ctx) - if err != nil { - t.Fatalf("GetStatsContext: %v", err) - } - - tableRoutesV4, tableRoutesV6, err := db.GetLiveRouteCountsContext(ctx) - if err != nil { - t.Fatalf("GetLiveRouteCountsContext: %v", err) - } - - queryDistributionV4, queryDistributionV6, err := - db.GetPrefixDistributionContext(ctx) - if err != nil { - t.Fatalf("GetPrefixDistributionContext: %v", err) - } - - memoryRoutes, tableRoutes := stats.IPv4Routes, tableRoutesV4 - memoryDistribution := stats.IPv4PrefixDistribution - queryDistribution := queryDistributionV4 - - if ipVersion == ipv6Version { - memoryRoutes, tableRoutes = stats.IPv6Routes, tableRoutesV6 - memoryDistribution = stats.IPv6PrefixDistribution - queryDistribution = queryDistributionV6 - } - - if memoryRoutes != wantRoutes || tableRoutes != wantRoutes { - t.Errorf("IPv%d live routes: %d in memory, %d in the table, want %d", - ipVersion, memoryRoutes, tableRoutes, wantRoutes) - } - - if !slices.Equal(memoryDistribution, wantDistribution) || - !slices.Equal(queryDistribution, wantDistribution) { - t.Errorf("IPv%d prefix distribution: %v in memory, %v from the table, "+ - "want %v", ipVersion, memoryDistribution, queryDistribution, - wantDistribution) - } -} diff --git a/internal/routewatch/prefixhandler_table_internal_test.go b/internal/routewatch/prefixhandler_table_internal_test.go index 97969e2..b4b894f 100644 --- a/internal/routewatch/prefixhandler_table_internal_test.go +++ b/internal/routewatch/prefixhandler_table_internal_test.go @@ -8,6 +8,7 @@ import ( "net/netip" "reflect" "slices" + "strings" "testing" "time" @@ -84,13 +85,25 @@ func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) { testV6Prefix: {}, }, }, + { + name: "an announcement after the peer's session went down", + messages: []*ristypes.RISMessage{ + peerAnnouncement(testPeerIP, testV4Prefix), + sessionDown(), + peerAnnouncement(testPeerIP, otherV4Prefix), + }, + want: map[string]map[int][]string{ + testV4Prefix: {}, + otherV4Prefix: {testASNB: {testPeerIP}}, + }, + }, }) } // TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable hands the // prefix handler announcements whose AS path gives no origin. One whose path -// holds AS 0 removes the peer's route, as a withdrawal does; one whose path ends -// in an AS set of several members is kept with origin 0. +// is empty or holds AS 0 removes the peer's route, as a withdrawal does; one +// whose path ends in an AS set of several members is kept with origin 0. func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) { t.Parallel() @@ -110,6 +123,14 @@ func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) }, want: map[string]map[int][]string{testV4Prefix: {}}, }, + { + name: "an empty path", + messages: []*ristypes.RISMessage{ + peerAnnouncement(testPeerIP, testV4Prefix), + announceWithPath(ristypes.ASPath{}), + }, + want: map[string]map[int][]string{testV4Prefix: {}}, + }, { name: "a path with AS 0 before its end", messages: []*ristypes.RISMessage{ @@ -128,6 +149,55 @@ func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) }) } +// TestPrefixHandlerKeepsThePathOfAPathEndingInASet announces a prefix with AS +// paths that end in an AS set. Any member of a set of several may be the +// origin, so the routing table keeps the route with origin 0 (none); a set of +// one member counts as that AS. The kept AS path lists the set's members after +// the rest of the path. +func TestPrefixHandlerKeepsThePathOfAPathEndingInASet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path []any + wantOrigin int + wantPath []int + }{ + { + name: "a set of several members", + path: []any{testASNA, testASNB, []int{testASNC, testASND}}, + wantPath: []int{testASNA, testASNB, testASNC, testASND}, + }, + { + name: "a set of one member", + path: []any{testASNA, testASNB, []int{testASNC}}, + wantOrigin: testASNC, + wantPath: []int{testASNA, testASNB, testASNC}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + msg := peerAnnouncement(testPeerIP, testV4Prefix) + msg.Path = decodePath(t, tt.path...) + + h := prefixHandlerWithoutTimer(newMockStore()) + h.HandleMessage(msg) + + origins := h.table.Origins(netip.MustParsePrefix(testV4Prefix)) + if len(origins) != 1 || origins[0].ASN != tt.wantOrigin || + len(origins[0].Routes) != 1 || + !slices.Equal(origins[0].Routes[0].ASPath, tt.wantPath) { + t.Errorf("Origins(%s) = %+v, want one route with origin AS%d "+ + "and AS path %v", testV4Prefix, origins, tt.wantOrigin, + tt.wantPath) + } + }) + } +} + // 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) { @@ -343,8 +413,9 @@ func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) { // TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten stops a prefix handler, // with no deadline, whose routing table holds more changed pairs than one write // takes. Its last flush writes one write's worth, so Stop must log the pairs -// that no write took. The flush timer must not fire, or its writes could leave -// no pairs for Stop to log. +// that no write took, and must not say that the stop deadline passed. The +// flush timer must not fire, or its writes could leave no pairs for Stop to +// log. func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) { t.Parallel() @@ -364,7 +435,13 @@ func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) { written += n } - if logged := lastLogged(t, &logs); logged.TableChanges != pairs-written { + logged := lastLogged(t, &logs) + if strings.Contains(logged.Msg, "deadline") { + t.Errorf("Stop logged %q although its deadline had not passed", + logged.Msg) + } + + if logged.TableChanges != pairs-written { t.Errorf("Stop logged %d table changes not written, want %d", logged.TableChanges, pairs-written) } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 1f5fa29..53906bd 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -83,7 +83,7 @@ const ( maxASSearchResults = 50 ) -// IP versions as database.LiveRoute.IPVersion records them. +// IP versions as LiveRoute.IPVersion records them. const ( ipVersionV4 = 4 ipVersionV6 = 6 @@ -233,34 +233,34 @@ type WHOISStatsInfo struct { // //nolint:tagliatelle // field names are the existing JSON API type Stats struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - TotalWireBytes uint64 `json:"total_wire_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` - Connected bool `json:"connected"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` - MemoryUsage string `json:"memory_usage"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - Peers int `json:"peers"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - OldestRoute *time.Time `json:"oldest_route,omitempty"` - NewestRoute *time.Time `json:"newest_route,omitempty"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` - IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` - WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + TotalWireBytes uint64 `json:"total_wire_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` + Connected bool `json:"connected"` + GoVersion string `json:"go_version"` + Goroutines int `json:"goroutines"` + MemoryUsage string `json:"memory_usage"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + Peers int `json:"peers"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + OldestRoute *time.Time `json:"oldest_route,omitempty"` + NewestRoute *time.Time `json:"newest_route,omitempty"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"` + WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` // OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers // carry but whose kept routes were all withdrawn. @@ -394,12 +394,12 @@ func (s *Server) statusStats( // prefixDistribution lists the mask lengths that have at least one prefix, in // ascending order, with their prefix counts. countsByLength[n] is the number // of prefixes of mask length n. -func prefixDistribution(countsByLength []int) []database.PrefixDistribution { - var distribution []database.PrefixDistribution +func prefixDistribution(countsByLength []int) []PrefixDistribution { + var distribution []PrefixDistribution for maskLength, count := range countsByLength { if count > 0 { - distribution = append(distribution, database.PrefixDistribution{ + distribution = append(distribution, PrefixDistribution{ MaskLength: maskLength, Count: count, }) } @@ -508,39 +508,39 @@ type StreamStats struct { // //nolint:tagliatelle // field names are the existing JSON API type StatsResponse struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - TotalWireBytes uint64 `json:"total_wire_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` - Connected bool `json:"connected"` - ConnectionDuration string `json:"connection_duration"` - ReconnectCount uint64 `json:"reconnect_count"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` - MemoryUsage string `json:"memory_usage"` - GC GCStats `json:"gc"` - Stream StreamStats `json:"stream"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - Peers int `json:"peers"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - OldestRoute *time.Time `json:"oldest_route,omitempty"` - NewestRoute *time.Time `json:"newest_route,omitempty"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` - HandlerStats []HandlerStatsInfo `json:"handler_stats"` - IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` - WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + TotalWireBytes uint64 `json:"total_wire_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` + Connected bool `json:"connected"` + ConnectionDuration string `json:"connection_duration"` + ReconnectCount uint64 `json:"reconnect_count"` + GoVersion string `json:"go_version"` + Goroutines int `json:"goroutines"` + MemoryUsage string `json:"memory_usage"` + GC GCStats `json:"gc"` + Stream StreamStats `json:"stream"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + Peers int `json:"peers"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + OldestRoute *time.Time `json:"oldest_route,omitempty"` + NewestRoute *time.Time `json:"newest_route,omitempty"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + HandlerStats []HandlerStatsInfo `json:"handler_stats"` + IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"` + WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` OriginsWithoutKeptRoute int `json:"origins_without_kept_route"` } @@ -753,7 +753,7 @@ type IPLookupResponse struct { // fields. IP hides the IPInfo field of the same name, so that an address with // no route has it too. type IPLookupResult struct { - *database.IPInfo + *IPInfo IP string `json:"ip"` PTR []string `json:"ptr,omitempty"` @@ -813,10 +813,10 @@ func (s *Server) handleIPInfo() http.HandlerFunc { } switch { - case errors.Is(err, database.ErrNoRoute): + case errors.Is(err, ErrNoRoute): // The address still gets a result, without route fields. ipInfo = nil - case errors.Is(err, database.ErrInvalidIP): + case errors.Is(err, ErrInvalidIP): response.Errors = append(response.Errors, ip+": "+err.Error()) continue @@ -860,15 +860,15 @@ func (s *Server) handleIPInfo() http.HandlerFunc { // route. The database gives the prefix's first-seen time and the AS's WHOIS // details. It returns ErrNoRoute when no prefix holds the address, and // ErrInvalidIP when ip does not parse. -func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error) { +func (s *Server) ipInfo(ctx context.Context, ip string) (*IPInfo, error) { addr, err := netip.ParseAddr(ip) if err != nil { - return nil, fmt.Errorf("%w: %s", database.ErrInvalidIP, ip) + return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) } summary, found := s.table.Lookup(addr) if !found { - return nil, fmt.Errorf("%w for IP %s", database.ErrNoRoute, ip) + return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) } firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix) @@ -881,7 +881,7 @@ func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error return nil, err } - info := &database.IPInfo{ + info := &IPInfo{ IP: ip, Netblock: summary.Prefix.String(), MaskLength: summary.Prefix.Bits(), @@ -1165,7 +1165,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc { } if err != nil { - if errors.Is(err, database.ErrNoRoute) { + if errors.Is(err, ErrNoRoute) { s.writeJSONError(w, http.StatusNotFound, err.Error()) } else { s.logger.Error("Failed to get AS details", "asn", asn, "error", err) @@ -1201,9 +1201,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc { // routing table all lack. func (s *Server) asDetails( ctx context.Context, asn int, -) (*database.ASN, []database.LiveRoute, error) { +) (*database.ASN, []LiveRoute, error) { if asn == ristypes.NoOrigin { - return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn) + return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) } asInfo, known, err := s.db.GetAS(ctx, asn) @@ -1213,13 +1213,13 @@ func (s *Server) asDetails( originated := s.table.OriginatedPrefixes(asn) if !known && len(originated) == 0 { - return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn) + return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) } - prefixes := make([]database.LiveRoute, 0, len(originated)) + prefixes := make([]LiveRoute, 0, len(originated)) for _, p := range originated { - route := database.LiveRoute{ + route := LiveRoute{ Prefix: p.Prefix.String(), MaskLength: p.Prefix.Bits(), IPVersion: ipVersionOf(p.Prefix), @@ -1239,9 +1239,9 @@ func (s *Server) asDetails( // splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither // group is nil, so that a JSON answer holds [] for an empty one and not null. func splitRoutesByIPVersion( - routes []database.LiveRoute, -) ([]database.LiveRoute, []database.LiveRoute) { - ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{} + routes []LiveRoute, +) ([]LiveRoute, []LiveRoute) { + ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{} for _, route := range routes { if route.IPVersion == ipVersionV4 { @@ -1256,8 +1256,8 @@ func splitRoutesByIPVersion( // sortRoutesByMaskLength sorts routes by mask length, shortest first, and // routes with the same mask length by network address. -func sortRoutesByMaskLength(routes []database.LiveRoute) { - slices.SortFunc(routes, func(a, b database.LiveRoute) int { +func sortRoutesByMaskLength(routes []LiveRoute) { + slices.SortFunc(routes, func(a, b LiveRoute) int { prefixA, _ := netip.ParsePrefix(a.Prefix) prefixB, _ := netip.ParsePrefix(b.Prefix) @@ -1294,7 +1294,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { origins := s.table.Origins(parsed) if len(origins) == 0 { s.writeJSONError(w, http.StatusNotFound, - database.ErrNoRoute.Error()+": "+prefix) + ErrNoRoute.Error()+": "+prefix) return } @@ -1302,7 +1302,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { routes := prefixRoutes(parsed, origins) // Group by origin AS - originMap := make(map[int][]database.LiveRoute) + originMap := make(map[int][]LiveRoute) for _, route := range routes { originMap[route.OriginASN] = append(originMap[route.OriginASN], route) } @@ -1392,7 +1392,7 @@ func (s *Server) handleASDetail() http.HandlerFunc { } if err != nil { - if errors.Is(err, database.ErrNoRoute) { + if errors.Is(err, ErrNoRoute) { http.Error(w, "AS not found", http.StatusNotFound) } else { s.logger.Error("Failed to get AS details", "error", err) @@ -1425,8 +1425,8 @@ func (s *Server) handleASDetail() http.HandlerFunc { // Prepare template data data := struct { ASN *database.ASN - IPv4Prefixes []database.LiveRoute - IPv6Prefixes []database.LiveRoute + IPv4Prefixes []LiveRoute + IPv6Prefixes []LiveRoute TotalCount int IPv4Count int IPv6Count int @@ -1585,11 +1585,11 @@ func (s *Server) originASNs( // them, since the routing table keeps only their addresses. func prefixRoutes( prefix netip.Prefix, origins []routetable.Origin, -) []database.LiveRoute { - var routes []database.LiveRoute +) []LiveRoute { + var routes []LiveRoute for _, origin := range origins { - base := database.LiveRoute{ + base := LiveRoute{ Prefix: prefix.String(), MaskLength: prefix.Bits(), IPVersion: ipVersionOf(prefix), @@ -1630,14 +1630,14 @@ type ASPathEntry struct { // EnhancedRoute is a live route with the handle of each AS in its path. type EnhancedRoute struct { - database.LiveRoute + LiveRoute ASPathWithHandle []ASPathEntry } // routesWithASPathHandles looks up the handle of each AS in the path of each // route. -func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute { +func routesWithASPathHandles(routes []LiveRoute) []EnhancedRoute { enhancedRoutes := make([]EnhancedRoute, len(routes)) for i, route := range routes { @@ -1736,7 +1736,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF // EnhancedPrefix is a prefix on the prefix length page, with the origin AS // that most peers carry it with, that AS's description, and its age. type EnhancedPrefix struct { - database.LiveRoute + LiveRoute OriginASDescription string Age string diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index d2b0152..f4dd8f7 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -158,8 +158,8 @@ type tableStats struct { OldestRoute time.Time `json:"oldest_route"` NewestRoute time.Time `json:"newest_route"` - IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` + IPv4Distribution []PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6Distribution []PrefixDistribution `json:"ipv6_prefix_distribution"` } // TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200 @@ -211,8 +211,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) { OriginsWithoutKeptRoute: 1, OldestRoute: testRouteTime(0), NewestRoute: testRouteTime(4), - IPv4Distribution: []database.PrefixDistribution{{MaskLength: 24, Count: 2}}, - IPv6Distribution: []database.PrefixDistribution{{MaskLength: 32, Count: 1}}, + IPv4Distribution: []PrefixDistribution{{MaskLength: 24, Count: 2}}, + IPv6Distribution: []PrefixDistribution{{MaskLength: 32, Count: 1}}, } for name, handler := range handlers { @@ -422,27 +422,9 @@ func TestIPLookupsReadPercentEncodedAddresses(t *testing.T) { } } -// errASDetailsLookupFailed is what failingASDetailsDB answers every AS details -// lookup with. -var errASDetailsLookupFailed = errors.New("AS details lookup failed") - -// failingASDetailsDB wraps a real store and fails every AS details lookup. -type failingASDetailsDB struct { - database.Store -} - -func (failingASDetailsDB) GetASDetailsContext( - _ context.Context, _ int, -) (*database.ASN, []database.LiveRoute, error) { - return nil, nil, errASDetailsLookupFailed -} - // TestPrefixDetailShowsOriginNames checks that the prefix page shows each -// origin AS's handle and description without looking up the AS's details, -// which also read every route the AS originates. Before the fix the page did -// that lookup once per origin, so with it failing the names were missing -// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows -// handles too, so the test looks for the origin list's "(handle)" form. +// origin AS's handle and description. The AS path column shows handles too, so +// the test looks for the origin list's "(handle)" form. func TestPrefixDetailShowsOriginNames(t *testing.T) { t.Parallel() @@ -465,7 +447,7 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) { announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0)) } - s := New(failingASDetailsDB{db}, table, + s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) rec := httptest.NewRecorder() s.router.ServeHTTP(rec, httptest.NewRequestWithContext( @@ -1716,9 +1698,9 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) { //nolint:tagliatelle // field names are the existing JSON API var body struct { Data struct { - ASN database.ASN `json:"asn"` - IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"` - IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"` + ASN database.ASN `json:"asn"` + IPv4Prefixes []LiveRoute `json:"ipv4_prefixes"` + IPv6Prefixes []LiveRoute `json:"ipv6_prefixes"` } `json:"data"` } @@ -1863,9 +1845,9 @@ func TestSortRoutesByMaskLength(t *testing.T) { } for _, tt := range tests { - routes := make([]database.LiveRoute, 0, len(tt.prefixes)) + routes := make([]LiveRoute, 0, len(tt.prefixes)) for _, prefix := range tt.prefixes { - routes = append(routes, database.LiveRoute{Prefix: prefix}) + routes = append(routes, LiveRoute{Prefix: prefix}) } sortRoutesByMaskLength(routes) diff --git a/internal/server/handlers_table_internal_test.go b/internal/server/handlers_table_internal_test.go index 515a163..5ff1348 100644 --- a/internal/server/handlers_table_internal_test.go +++ b/internal/server/handlers_table_internal_test.go @@ -11,6 +11,7 @@ import ( "time" "git.eeqj.de/sneak/routewatch/internal/database" + "git.eeqj.de/sneak/routewatch/internal/ristypes" "git.eeqj.de/sneak/routewatch/internal/routetable" ) @@ -134,6 +135,34 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) { } } +// TestIPLookupAsksForNoWHOISLookupOfOriginZero checks that the IP lookup asks +// for no WHOIS lookup of AS 0, which means no known origin. It asks for one of +// an AS whose WHOIS details were never fetched or are stale, and not of one +// whose details are fresh. +func TestIPLookupAsksForNoWHOISLookupOfOriginZero(t *testing.T) { + t.Parallel() + + fresh := time.Now().Add(-time.Hour) + stale := time.Now().Add(-2 * whoisStaleThreshold) + + tests := []struct { + name string + asInfo database.ASN + want bool + }{ + {"no known origin", database.ASN{ASN: ristypes.NoOrigin}, false}, + {"details never fetched", database.ASN{ASN: mostPeersASN}, true}, + {"stale details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &stale}, true}, + {"fresh details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &fresh}, false}, + } + + for _, tt := range tests { + if got := needsWHOISRefresh(&tt.asInfo); got != tt.want { + t.Errorf("%s: needsWHOISRefresh = %t, want %t", tt.name, got, tt.want) + } + } +} + // prefixRoute is one route of the prefix API's answer. A field the answer // leaves out stays nil. // diff --git a/internal/server/models.go b/internal/server/models.go new file mode 100644 index 0000000..858a770 --- /dev/null +++ b/internal/server/models.go @@ -0,0 +1,67 @@ +package server + +import ( + "errors" + "time" +) + +var ( + // ErrInvalidIP says that an IP address is malformed. The IP lookup returns + // it. + ErrInvalidIP = errors.New("invalid IP address") + // ErrNoRoute says that the routing table has no route for an address, a + // prefix or an AS. The lookups return it. + ErrNoRoute = errors.New("no route found") +) + +// LiveRoute represents a route in the live routing table. JSON leaves out an +// empty AS path, next hop or time: the routing table in memory keeps them only +// for the two newest routes of each prefix and origin AS. +// +//nolint:tagliatelle // field names are the existing JSON API +type LiveRoute struct { + Prefix string `json:"prefix"` + MaskLength int `json:"mask_length"` + IPVersion int `json:"ip_version"` + OriginASN int `json:"origin_asn"` + PeerIP string `json:"peer_ip"` + ASPath []int `json:"as_path,omitempty"` + NextHop string `json:"next_hop,omitempty"` + LastUpdated time.Time `json:"last_updated,omitzero"` +} + +// PrefixDistribution represents the distribution of prefixes by mask length +// +//nolint:tagliatelle // field names are the existing JSON API +type PrefixDistribution struct { + MaskLength int `json:"mask_length"` + Count int `json:"count"` +} + +// IPInfo represents comprehensive IP information for the /ip endpoint +// +//nolint:tagliatelle // field names are the existing JSON API +type IPInfo struct { + IP string `json:"ip"` + Netblock string `json:"netblock"` + MaskLength int `json:"mask_length"` + IPVersion int `json:"ip_version"` + NumPeers int `json:"num_peers"` + // AS information + ASN int `json:"asn"` + ASName string `json:"as_name,omitempty"` + Handle string `json:"handle,omitempty"` + Description string `json:"description,omitempty"` + OrgName string `json:"org_name,omitempty"` + OrgID string `json:"org_id,omitempty"` + Address string `json:"address,omitempty"` + CountryCode string `json:"country_code,omitempty"` + AbuseEmail string `json:"abuse_email,omitempty"` + RIR string `json:"rir,omitempty"` + // Timestamps. LastSeen is zero, and left out of JSON, when the prefix + // keeps no route. + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen,omitzero"` + // Indicates if WHOIS data needs refresh (not serialized) + NeedsWHOISRefresh bool `json:"-"` +} diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go index fb3446b..c59fe82 100644 --- a/internal/templates/templates_test.go +++ b/internal/templates/templates_test.go @@ -7,6 +7,7 @@ import ( "testing" "git.eeqj.de/sneak/routewatch/internal/database" + "git.eeqj.de/sneak/routewatch/internal/server" "git.eeqj.de/sneak/routewatch/internal/templates" ) @@ -114,14 +115,14 @@ func TestASDetailPrefixListsFold(t *testing.T) { prefixV6 = "2001:db8::/32" ) - ipv4 := []database.LiveRoute{{Prefix: prefixV4}} - ipv6 := []database.LiveRoute{{Prefix: prefixV6}} + ipv4 := []server.LiveRoute{{Prefix: prefixV4}} + ipv6 := []server.LiveRoute{{Prefix: prefixV6}} // The fields the AS page handler gives the template. data := struct { ASN *database.ASN - IPv4Prefixes []database.LiveRoute - IPv6Prefixes []database.LiveRoute + IPv4Prefixes []server.LiveRoute + IPv6Prefixes []server.LiveRoute TotalCount int IPv4Count int IPv6Count int