From 1c65656b580bd1f48aad9dd6730ab36d15ce38b5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 17:22:32 +0100 Subject: [PATCH] Remove the per-peer route tables (closes #122) The routing table in memory now holds every route, so the two per-peer route tables go, with every write, delete, count and read that served them. The prefix handler applies each update to the table, writes the table's changes every second, and stores each new prefix with its first-seen time. The peer delete, its retry on the next flush and the updates held for it go too. The types and errors of the server's answers move to internal/server. The image's memory settings fit a full table of about 1 GB: GOMEMLIMIT of 2.5 GiB, a 512 MiB page cache for the writing connection, and SQLite heap limits of 768 MiB and 1 GiB. The README describes the new table, its database copy and the memory budget. Model: opus-5-5 --- Dockerfile | 9 +- README.md | 222 +-- TODO.md | 18 +- internal/database/counts.go | 109 +- internal/database/counts_internal_test.go | 602 +------ internal/database/database.go | 1469 +---------------- internal/database/database_internal_test.go | 1325 +-------------- internal/database/interface.go | 61 +- internal/database/models.go | 59 - internal/database/schema.sql | 59 - internal/database/utils.go | 14 - internal/routewatch/app.go | 7 +- .../app_integration_internal_test.go | 164 +- .../routewatch/ashandler_internal_test.go | 4 +- .../routewatch/batchwrite_internal_test.go | 40 +- internal/routewatch/prefixhandler.go | 430 +---- .../routewatch/prefixhandler_internal_test.go | 960 +---------- .../prefixhandler_table_internal_test.go | 87 +- internal/server/handlers.go | 188 +-- internal/server/handlers_internal_test.go | 42 +- .../server/handlers_table_internal_test.go | 29 + internal/server/models.go | 67 + internal/templates/templates_test.go | 9 +- 23 files changed, 701 insertions(+), 5273 deletions(-) create mode 100644 internal/server/models.go 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