package database import ( "cmp" "context" "database/sql" _ "embed" "encoding/json" "errors" "fmt" "maps" "math/rand/v2" "net" "net/netip" "os" "path/filepath" "runtime" "slices" "sync" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/pkg/asinfo" _ "github.com/mattn/go-sqlite3" // CGO SQLite driver ) // IMPORTANT: NO schema changes are to be made outside of schema.sql // We do NOT support migrations. All schema changes MUST be made in schema.sql only. // //go:embed schema.sql var dbSchema string 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. const ( // maxConns is the size of the connection pool: the connection kept for // writing and 9 reading connections. maxConns = 10 // sqliteReadCacheSizeKiB is the page cache of each reading connection; // negative means KiB. -16384 = 16 MiB, at most 144 MiB for the 9. A reading // connection's cache is emptied whenever another connection has written // 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 // sqliteBusyTimeoutMs is how long a connection waits on a locked database. sqliteBusyTimeoutMs = 5000 // sqliteSoftHeapLimitBytes (1.5 GiB) 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 // instead of growing the C heap without bound. sqliteHardHeapLimitBytes = 2147483648 ) // Settings of the ANALYZE that Analyze runs. const ( // analysisLimit is about how many rows of each index ANALYZE reads // (PRAGMA analysis_limit); SQLite estimates the rest. analysisLimit = 1000 // optimizeAllTables is the PRAGMA optimize mask that runs ANALYZE (0x2) // on every table that has an index without statistics or whose size has // changed tenfold since it was last analyzed, not only on the tables this // connection has queried (0x10000). 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") errInvalidASN = errors.New("invalid ASN") errSameASNInPeering = errors.New("cannot create peering with same ASN") ) // Database manages the SQLite database connection and operations. type Database struct { db *sql.DB // writer is the connection kept for writing, taken from db when the // database opens. Every write uses it while holding mu. writer *sql.Conn logger *logger.Logger path string mu sync.Mutex lockedAt time.Time lockedBy string counts *liveCounts } // New creates a new database connection and initializes the schema. func New(cfg *config.Config, logger *logger.Logger) (*Database, error) { dbPath := filepath.Join(cfg.GetStateDir(), "db.sqlite") // Log database path logger.Info("Opening database", "path", dbPath) // Ensure directory exists dir := filepath.Dir(dbPath) err := os.MkdirAll(dir, dirPermissions) if err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) } // Per-connection SQLite settings go in the DSN so every pooled connection // gets them, not just the one that runs the Initialize pragmas. _txlock= // immediate makes every transaction take the write lock at BEGIN. Without it // a transaction that reads before writing starts as a reader and, when it // then writes while another connection holds the write lock, fails at once // with "database is locked" without waiting for _busy_timeout. // _auto_vacuum=incremental lets Vacuum return free pages to the filesystem. // SQLite only accepts it before the database file is first written, and the // switch to WAL writes it, so it must be here: the driver applies it on open, // before _journal_mode. On an existing file it changes nothing. dsn := fmt.Sprintf( "file:%s?_cache_size=%d&_synchronous=OFF&_busy_timeout=%d"+ "&_auto_vacuum=incremental&_journal_mode=WAL&_txlock=immediate", dbPath, sqliteReadCacheSizeKiB, sqliteBusyTimeoutMs, ) db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } ctx := context.Background() err = db.PingContext(ctx) if err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } // Set connection pool parameters // Multiple connections allow concurrent reads while writes are serialized db.SetMaxOpenConns(maxConns) db.SetMaxIdleConns(maxConns) db.SetConnMaxLifetime(0) writer, err := db.Conn(ctx) if err != nil { return nil, fmt.Errorf("failed to open the writing connection: %w", err) } _, err = writer.ExecContext(ctx, fmt.Sprintf("PRAGMA cache_size=%d", sqliteWriteCacheSizeKiB)) if err != nil { return nil, fmt.Errorf("failed to set the writing connection's cache: %w", err) } database := &Database{ db: db, writer: writer, logger: logger, path: dbPath, counts: &liveCounts{}, } err = database.Initialize() if err != nil { return nil, fmt.Errorf("failed to initialize database: %w", err) } // Seed the in-memory statistics counters from the tables once, before the // streamer starts writing. From here on every write keeps them current, so // the stats endpoints never scan the tables to report counts. err = database.seedCounts(context.Background()) if err != nil { return nil, fmt.Errorf("failed to seed statistics counters: %w", err) } return database, nil } // Initialize creates the database schema if it doesn't exist. func (d *Database) Initialize() error { // Set SQLite pragmas for performance. Per-connection settings (cache_size, // synchronous, busy_timeout, journal_mode) live in the DSN, and New sets the // writing connection's cache_size; temp_store is left at its default so // DISTINCT temp B-trees spill to disk instead of C heap. The heap limits // below are process-wide, so setting them once here is enough for the whole // pool. pragmas := []string{ "PRAGMA journal_mode=WAL", // Write-Ahead Logging fmt.Sprintf("PRAGMA soft_heap_limit=%d", sqliteSoftHeapLimitBytes), fmt.Sprintf("PRAGMA hard_heap_limit=%d", sqliteHardHeapLimitBytes), } for _, pragma := range pragmas { err := d.exec(pragma) if err != nil { d.logger.Warn("Failed to set pragma", "pragma", pragma, "error", err) } } // Run WAL checkpoint on startup to consolidate any existing WAL var walPages, checkpointedPages, movedPages int err := d.writer.QueryRowContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)", ).Scan(&walPages, &checkpointedPages, &movedPages) if err != nil { d.logger.Warn("Failed to checkpoint WAL on startup", "error", err) } else { d.logger.Info("WAL checkpoint on startup", "wal_pages", walPages, "checkpointed", checkpointedPages, "moved", movedPages, ) } err = d.exec(dbSchema) if err != nil { return err } return nil } // Close closes the writing connection and then the connection pool. 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 } // Deleting all live routes of a peer. The first query of each table selects the // mask length of each of the peer's routes whose prefix no other peer has a // live route for: the delete takes those prefixes out of the prefix // distribution. const ( peerOnlyRouteMaskLengthsV4SQL = `SELECT r.mask_length FROM live_routes_v4 AS r WHERE r.peer_ip = ? AND NOT EXISTS (SELECT 1 FROM live_routes_v4 AS o WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip)` deletePeerLiveRoutesV4SQL = `DELETE FROM live_routes_v4 WHERE peer_ip = ?` peerOnlyRouteMaskLengthsV6SQL = `SELECT r.mask_length FROM live_routes_v6 AS r WHERE r.peer_ip = ? AND NOT EXISTS (SELECT 1 FROM live_routes_v6 AS o WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip)` deletePeerLiveRoutesV6SQL = `DELETE FROM live_routes_v6 WHERE peer_ip = ?` ) // deletePeerRoutes deletes all live routes of peerIP 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, onlyRouteMaskLengthsSQL, deleteSQL, peerIP string, ) (int64, []int, error) { rows, err := tx.QueryContext(ctx, onlyRouteMaskLengthsSQL, peerIP) if err != nil { return 0, nil, err } defer func() { _ = rows.Close() }() var goneMaskLengths []int for rows.Next() { var maskLength int err := rows.Scan(&maskLength) if err != nil { return 0, nil, err } goneMaskLengths = append(goneMaskLengths, maskLength) } err = rows.Err() if err != nil { return 0, nil, err } res, err := tx.ExecContext(ctx, deleteSQL, peerIP) 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, // in one transaction, as when the peer's BGP session goes down. It returns how // many routes it deleted. func (d *Database) DeleteLiveRoutesOfPeer(peerIP string) (int, error) { d.lock("DeleteLiveRoutesOfPeer") defer d.unlock() tx, err := d.beginTx() if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer d.rollback(tx) ctx := context.Background() deletedV4, goneMaskLengthsV4, err := deletePeerRoutes(ctx, tx, peerOnlyRouteMaskLengthsV4SQL, deletePeerLiveRoutesV4SQL, peerIP) if err != nil { return 0, fmt.Errorf("failed to delete the IPv4 routes of peer %s: %w", peerIP, err) } deletedV6, goneMaskLengthsV6, err := deletePeerRoutes(ctx, tx, peerOnlyRouteMaskLengthsV6SQL, deletePeerLiveRoutesV6SQL, peerIP) if err != nil { return 0, fmt.Errorf("failed to delete the IPv6 routes of peer %s: %w", peerIP, err) } err = tx.Commit() if err != nil { return 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 + deletedV6), 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 // inserted. func insertPrefixesInTable( ctx context.Context, tx *loggingTx, prefixes map[string]time.Time, ipVersion int, ) (int, error) { if len(prefixes) == 0 { return 0, nil } family := ipv4Family insertSQL := "INSERT INTO prefixes_v4 (prefix, first_seen) VALUES (?, ?) " + "ON CONFLICT (prefix) DO NOTHING" if ipVersion == ipVersionV6 { family = ipv6Family insertSQL = "INSERT INTO prefixes_v6 (prefix, first_seen) VALUES (?, ?) " + "ON CONFLICT (prefix) DO NOTHING" } insertStmt, err := tx.PrepareContext(ctx, insertSQL) if err != nil { return 0, fmt.Errorf("failed to prepare %s insert statement: %w", family, err) } defer func() { _ = insertStmt.Close() }() var newPrefixes int // The rows are written in key order, as UpsertLiveRouteBatch writes routes. for _, prefix := range slices.Sorted(maps.Keys(prefixes)) { res, err := insertStmt.ExecContext(ctx, prefix, prefixes[prefix]) if err != nil { return 0, fmt.Errorf("failed to insert prefix %s: %w", prefix, err) } // One row for a new prefix, none for one already stored. inserted, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("failed to count inserted prefix %s: %w", prefix, err) } if inserted > 0 { newPrefixes++ } } return newPrefixes, nil } // UpdatePrefixesBatch stores, in a single transaction, the prefixes that are // not stored yet, each first seen at its time in prefixes: the IPv4 prefixes // first and then the IPv6 prefixes. func (d *Database) UpdatePrefixesBatch(prefixes map[string]time.Time) error { if len(prefixes) == 0 { return nil } d.lock("UpdatePrefixesBatch") defer d.unlock() tx, err := d.beginTx() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer d.rollback(tx) prefixesV4 := make(map[string]time.Time) prefixesV6 := make(map[string]time.Time) for prefix, timestamp := range prefixes { if detectIPVersion(prefix) == ipVersionV4 { prefixesV4[prefix] = timestamp } else { prefixesV6[prefix] = timestamp } } ctx := context.Background() newV4, err := insertPrefixesInTable(ctx, tx, prefixesV4, ipVersionV4) if err != nil { return err } newV6, err := insertPrefixesInTable(ctx, tx, prefixesV6, ipVersionV6) if err != nil { return err } err = tx.Commit() if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } d.counts.addPrefixes(newV4, newV6) return nil } // newASN returns a new ASN record first and last seen at timestamp, with the // handle and description from the AS info lookup when it knows the number. func newASN(number int, timestamp time.Time) ASN { asn := ASN{ ASN: number, FirstSeen: timestamp, LastSeen: timestamp, } // Look up ASN info if info, ok := asinfo.Get(number); ok { asn.Handle = info.Handle asn.Description = info.Description } return asn } // upsertASN updates the last_seen time of ASN number with the given // statements, inserting the ASN when it is new. It reports whether it inserted // it. func upsertASN( ctx context.Context, selectStmt, updateStmt, insertStmt *sql.Stmt, number int, timestamp time.Time, ) (bool, error) { var ( asn ASN handle, description sql.NullString ) err := selectStmt.QueryRowContext(ctx, number).Scan(&asn.ASN, &handle, &description, &asn.FirstSeen, &asn.LastSeen) if err == nil { // ASN exists, update last_seen _, err = updateStmt.ExecContext(ctx, timestamp, number) if err != nil { return false, fmt.Errorf("failed to update ASN %d: %w", number, err) } return false, nil } if !errors.Is(err, sql.ErrNoRows) { return false, fmt.Errorf("failed to query ASN %d: %w", number, err) } // ASN doesn't exist, create it asn = newASN(number, timestamp) _, err = insertStmt.ExecContext(ctx, asn.ASN, asn.Handle, asn.Description, asn.FirstSeen, asn.LastSeen) if err != nil { return false, fmt.Errorf("failed to insert ASN %d: %w", number, err) } return true, nil } // GetOrCreateASNBatch creates or updates multiple ASNs in a single transaction func (d *Database) GetOrCreateASNBatch(asns map[int]time.Time) error { if len(asns) == 0 { return nil } d.lock("GetOrCreateASNBatch") defer d.unlock() ctx := context.Background() tx, err := d.beginTx() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer d.rollback(tx) // Prepare statements selectStmt, err := tx.PrepareContext(ctx, "SELECT asn, handle, description, first_seen, last_seen "+ "FROM asns WHERE asn = ?") if err != nil { return fmt.Errorf("failed to prepare select statement: %w", err) } defer func() { _ = selectStmt.Close() }() updateStmt, err := tx.PrepareContext(ctx, "UPDATE asns SET last_seen = ? WHERE asn = ?") if err != nil { return fmt.Errorf("failed to prepare update statement: %w", err) } defer func() { _ = updateStmt.Close() }() insertStmt, err := tx.PrepareContext(ctx, "INSERT INTO asns (asn, handle, description, first_seen, last_seen) "+ "VALUES (?, ?, ?, ?, ?)") if err != nil { return fmt.Errorf("failed to prepare insert statement: %w", err) } defer func() { _ = insertStmt.Close() }() var newASNs int for number, timestamp := range asns { inserted, err := upsertASN(ctx, selectStmt, updateStmt, insertStmt, number, timestamp) if err != nil { return err } if inserted { newASNs++ } } err = tx.Commit() if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } d.counts.addASNs(newASNs) return nil } // GetOrCreateASN retrieves an existing ASN or creates a new one if it doesn't // exist. func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) { d.lock("GetOrCreateASN") defer d.unlock() tx, err := d.beginTx() if err != nil { return nil, err } defer d.rollback(tx) var ( asn ASN handle, description sql.NullString ) err = tx.QueryRow("SELECT asn, handle, description, first_seen, last_seen "+ "FROM asns WHERE asn = ?", number). Scan(&asn.ASN, &handle, &description, &asn.FirstSeen, &asn.LastSeen) if err == nil { // ASN exists, update last_seen asn.Handle = handle.String asn.Description = description.String _, err = tx.Exec("UPDATE asns SET last_seen = ? WHERE asn = ?", timestamp, number) if err != nil { return nil, err } asn.LastSeen = timestamp err = tx.Commit() if err != nil { d.logger.Error("Failed to commit transaction for ASN update", "asn", number, "error", err) return nil, err } return &asn, nil } if !errors.Is(err, sql.ErrNoRows) { return nil, err } // ASN doesn't exist, create it with ASN info lookup asn = newASN(number, timestamp) _, err = tx.Exec("INSERT INTO asns "+ "(asn, handle, description, first_seen, last_seen) "+ "VALUES (?, ?, ?, ?, ?)", asn.ASN, asn.Handle, asn.Description, asn.FirstSeen, asn.LastSeen) if err != nil { return nil, err } err = tx.Commit() if err != nil { d.logger.Error("Failed to commit transaction for ASN creation", "asn", number, "error", err) return nil, err } d.counts.addASNs(1) return &asn, nil } // GetOrCreatePrefix retrieves an existing prefix or creates a new one if it // doesn't exist. func (d *Database) GetOrCreatePrefix( prefix string, timestamp time.Time) (*Prefix, error) { d.lock("GetOrCreatePrefix") defer d.unlock() tx, err := d.beginTx() if err != nil { return nil, err } defer d.rollback(tx) // Determine table based on IP version ipVersion := detectIPVersion(prefix) tableName := "prefixes_v4" if ipVersion == ipVersionV6 { tableName = "prefixes_v6" } var p Prefix query := fmt.Sprintf("SELECT prefix, first_seen FROM %s WHERE prefix = ?", tableName) err = tx.QueryRow(query, prefix).Scan(&p.Prefix, &p.FirstSeen) if err == nil { // Prefix exists; the deferred rollback ends the read. p.IPVersion = ipVersion return &p, nil } if !errors.Is(err, sql.ErrNoRows) { return nil, err } // Prefix doesn't exist, create it p = Prefix{Prefix: prefix, IPVersion: ipVersion, FirstSeen: timestamp} insertQuery := fmt.Sprintf("INSERT INTO %s "+ "(prefix, first_seen) VALUES (?, ?)", tableName) _, err = tx.Exec(insertQuery, p.Prefix, p.FirstSeen) if err != nil { return nil, err } err = tx.Commit() if err != nil { d.logger.Error("Failed to commit transaction for prefix creation", "prefix", prefix, "error", err) return nil, err } if ipVersion == ipVersionV4 { d.counts.addPrefixes(1, 0) } else { d.counts.addPrefixes(0, 1) } return &p, nil } // RecordAnnouncement inserts a new BGP announcement or withdrawal into the // database. func (d *Database) RecordAnnouncement(announcement *Announcement) error { d.lock("RecordAnnouncement") defer d.unlock() err := d.exec(` INSERT INTO announcements (id, prefix_id, peer_asn, origin_asn, path, next_hop, timestamp, is_withdrawal) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, announcement.ID.String(), announcement.PrefixID.String(), announcement.PeerASN, announcement.OriginASN, announcement.Path, announcement.NextHop, announcement.Timestamp, announcement.IsWithdrawal) return err } // RecordPeering records a peering relationship between two ASNs. func (d *Database) RecordPeering(asA, asB int, timestamp time.Time) error { // Validate ASNs if asA <= 0 || asB <= 0 { return fmt.Errorf("%w: asA=%d, asB=%d", errInvalidASN, asA, asB) } if asA == asB { return fmt.Errorf("%w: %d", errSameASNInPeering, asA) } // Normalize: ensure asA < asB if asA > asB { asA, asB = asB, asA } d.lock("RecordPeering") defer d.unlock() tx, err := d.beginTx() if err != nil { return err } defer d.rollback(tx) var exists bool err = tx.QueryRow( "SELECT EXISTS(SELECT 1 FROM peerings WHERE as_a = ? AND as_b = ?)", asA, asB).Scan(&exists) if err != nil { return err } if exists { _, err = tx.Exec( "UPDATE peerings SET last_seen = ? WHERE as_a = ? AND as_b = ?", timestamp, asA, asB) } else { _, err = tx.Exec(` INSERT INTO peerings (id, as_a, as_b, first_seen, last_seen) VALUES (?, ?, ?, ?, ?)`, generateUUID().String(), asA, asB, timestamp, timestamp) } if err != nil { return err } err = tx.Commit() if err != nil { d.logger.Error("Failed to commit transaction for peering", "as_a", asA, "as_b", asB, "error", err, ) return err } if !exists { d.counts.addPeerings(1) } return nil } // UpdatePeerBatch updates or creates multiple BGP peer records in a single transaction func (d *Database) UpdatePeerBatch(peers map[string]PeerUpdate) error { if len(peers) == 0 { return nil } d.lock("UpdatePeerBatch") defer d.unlock() ctx := context.Background() tx, err := d.beginTx() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer d.rollback(tx) // Prepare statements checkStmt, err := tx.PrepareContext(ctx, "SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)") if err != nil { return fmt.Errorf("failed to prepare check statement: %w", err) } defer func() { _ = checkStmt.Close() }() updateStmt, err := tx.PrepareContext(ctx, "UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, "+ "last_message_type = ? WHERE peer_ip = ?") if err != nil { return fmt.Errorf("failed to prepare update statement: %w", err) } defer func() { _ = updateStmt.Close() }() insertStmt, err := tx.PrepareContext(ctx, "INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, "+ "last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { return fmt.Errorf("failed to prepare insert statement: %w", err) } defer func() { _ = insertStmt.Close() }() var newPeers int for _, update := range peers { var exists bool err = checkStmt.QueryRowContext(ctx, update.PeerIP).Scan(&exists) if err != nil { return fmt.Errorf("failed to check peer %s: %w", update.PeerIP, err) } if exists { _, err = updateStmt.ExecContext(ctx, update.PeerASN, update.Timestamp, update.MessageType, update.PeerIP) } else { _, err = insertStmt.ExecContext(ctx, generateUUID().String(), update.PeerIP, update.PeerASN, update.Timestamp, update.Timestamp, update.MessageType) } if err != nil { return fmt.Errorf("failed to update peer %s: %w", update.PeerIP, err) } if !exists { newPeers++ } } err = tx.Commit() if err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } d.counts.addPeers(newPeers) return nil } // UpdatePeer updates or creates a BGP peer record func (d *Database) UpdatePeer( peerIP string, peerASN int, messageType string, timestamp time.Time, ) error { d.lock("UpdatePeer") defer d.unlock() tx, err := d.beginTx() if err != nil { return err } defer d.rollback(tx) var exists bool err = tx.QueryRow( "SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)", peerIP, ).Scan(&exists) if err != nil { return err } if exists { _, err = tx.Exec( "UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, "+ "last_message_type = ? WHERE peer_ip = ?", peerASN, timestamp, messageType, peerIP, ) } else { _, err = tx.Exec( "INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, "+ "last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)", generateUUID().String(), peerIP, peerASN, timestamp, timestamp, messageType, ) } if err != nil { return err } err = tx.Commit() if err != nil { d.logger.Error("Failed to commit transaction for peer update", "peer_ip", peerIP, "peer_asn", peerASN, "error", err, ) return err } if !exists { d.counts.addPeers(1) } return nil } // GetStats returns database statistics func (d *Database) GetStats() (Stats, error) { return d.GetStatsContext(context.Background()) } // 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. The oldest/newest route timestamps are read from the // ends of the last_updated index, and the file size from a stat(). No part of // the read scans a table or a whole index. func (d *Database) GetStatsContext(ctx context.Context) (Stats, error) { var stats Stats // Row counts and prefix distribution from memory, as a single consistent // snapshot. d.counts.fill(&stats) // Database file size is a cheap stat() on the file. fileInfo, err := os.Stat(d.path) if err != nil { d.logger.Warn("Failed to get database file size", "error", err) } else { stats.FileSizeBytes = fileInfo.Size() } // Oldest and newest route timestamps read one row from each end of the // last_updated index (a log-time lookup, not a scan). Selecting the column // directly lets the driver parse the DATETIME into time.Time; the old // MIN/MAX union scan returned an untyped string that failed to scan and // logged a warning on every call. oldest, newest, err := d.routeTimestampRange(ctx) if err != nil { // Display-only fields; log but keep the rest of the stats. d.logger.Warn("Failed to get route timestamps", "error", err) } else { stats.OldestRoute = oldest stats.NewestRoute = newest } 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 detailed information about an AS including // prefixes with context support func (d *Database) GetASDetailsContext( ctx context.Context, asn int, ) (*ASN, []LiveRoute, error) { // Get AS information var ( asnInfo ASN handle, description sql.NullString ) err := d.db.QueryRowContext(ctx, "SELECT asn, handle, description, first_seen, last_seen "+ "FROM asns WHERE asn = ?", asn, ).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) } return nil, nil, fmt.Errorf("failed to query AS: %w", err) } asnInfo.Handle = handle.String asnInfo.Description = description.String // Get prefixes announced by this AS from both tables queryV4 := ` SELECT prefix, mask_length, MAX(last_updated) as last_updated FROM live_routes_v4 WHERE origin_asn = ? GROUP BY prefix, mask_length ` allPrefixes, err := d.originPrefixes(ctx, queryV4, asn, ipVersionV4) if err != nil { return &asnInfo, nil, err } queryV6 := ` SELECT prefix, mask_length, MAX(last_updated) as last_updated FROM live_routes_v6 WHERE origin_asn = ? GROUP BY prefix, mask_length ` prefixesV6, err := d.originPrefixes(ctx, queryV6, asn, ipVersionV6) if err != nil { return &asnInfo, allPrefixes, err } return &asnInfo, append(allPrefixes, prefixesV6...), nil } // ASPeer represents a peering relationship with another AS including handle, // description, and timestamps. // //nolint:tagliatelle // field names are the existing JSON API type ASPeer struct { ASN int `json:"asn"` Handle string `json:"handle"` Description string `json:"description"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` } // GetASPeers returns all ASes that peer with the given AS func (d *Database) GetASPeers(asn int) ([]ASPeer, error) { return d.GetASPeersContext(context.Background(), asn) } // GetASPeersContext returns all ASes that peer with the given AS with context // support func (d *Database) GetASPeersContext( ctx context.Context, asn int, ) ([]ASPeer, error) { query := ` SELECT CASE WHEN p.as_a = ? THEN p.as_b ELSE p.as_a END as peer_asn, COALESCE(a.handle, '') as handle, COALESCE(a.description, '') as description, p.first_seen, p.last_seen FROM peerings p LEFT JOIN asns a ON a.asn = CASE WHEN p.as_a = ? THEN p.as_b ELSE p.as_a END WHERE p.as_a = ? OR p.as_b = ? ORDER BY peer_asn ` rows, err := d.db.QueryContext(ctx, query, asn, asn, asn, asn) if err != nil { return nil, fmt.Errorf("failed to query AS peers: %w", err) } defer func() { _ = rows.Close() }() var peers []ASPeer for rows.Next() { var peer ASPeer err := rows.Scan(&peer.ASN, &peer.Handle, &peer.Description, &peer.FirstSeen, &peer.LastSeen) if err != nil { d.logger.Error("Failed to scan peer row", "error", err, "asn", asn) continue } peers = append(peers, peer) } err = rows.Err() if err != nil { return nil, fmt.Errorf("error iterating AS peers: %w", err) } 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, ) (int, error) { cutoff := time.Now().Add(-staleThreshold) // Select a random stale ASN using ORDER BY RANDOM() query := ` SELECT asn FROM asns WHERE whois_updated_at IS NULL OR whois_updated_at < ? ORDER BY RANDOM() LIMIT 1 ` var asn int err := d.db.QueryRowContext(ctx, query, cutoff).Scan(&asn) if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, ErrNoStaleASN } return 0, fmt.Errorf("failed to get stale ASN: %w", err) } return asn, nil } // WHOISStats contains statistics about WHOIS data freshness. // //nolint:tagliatelle // field names are the existing JSON API type WHOISStats struct { TotalASNs int `json:"total_asns"` StaleASNs int `json:"stale_asns"` FreshASNs int `json:"fresh_asns"` NeverFetched int `json:"never_fetched"` } // GetWHOISStats returns statistics about WHOIS data freshness. func (d *Database) GetWHOISStats( ctx context.Context, staleThreshold time.Duration, ) (*WHOISStats, error) { cutoff := time.Now().Add(-staleThreshold) query := ` SELECT COUNT(*) as total, COALESCE(SUM(CASE WHEN whois_updated_at IS NULL THEN 1 ELSE 0 END), 0) as never_fetched, COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at < ? THEN 1 ELSE 0 END), 0) as stale, COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at >= ? THEN 1 ELSE 0 END), 0) as fresh FROM asns ` var stats WHOISStats err := d.db.QueryRowContext(ctx, query, cutoff, cutoff).Scan( &stats.TotalASNs, &stats.NeverFetched, &stats.StaleASNs, &stats.FreshASNs, ) if err != nil { return nil, fmt.Errorf("failed to get WHOIS stats: %w", err) } return &stats, nil } // UpdateASNWHOIS updates an ASN record with WHOIS data. func (d *Database) UpdateASNWHOIS( ctx context.Context, update *ASNWHOISUpdate, ) error { d.lock("UpdateASNWHOIS") defer d.unlock() query := ` UPDATE asns SET as_name = ?, org_name = ?, org_id = ?, address = ?, country_code = ?, abuse_email = ?, abuse_phone = ?, tech_email = ?, tech_phone = ?, rir = ?, rir_registration_date = ?, rir_last_modified = ?, whois_raw = ?, whois_updated_at = ? WHERE asn = ? ` _, err := d.writer.ExecContext(ctx, query, update.ASName, update.OrgName, update.OrgID, update.Address, update.CountryCode, update.AbuseEmail, update.AbusePhone, update.TechEmail, update.TechPhone, update.RIR, update.RIRRegDate, update.RIRLastMod, update.WHOISRaw, time.Now(), update.ASN, ) if err != nil { return fmt.Errorf("failed to update ASN WHOIS: %w", err) } 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 // write lock, and returns ctx's error without running if ctx ended while it // waited: the writing connection it uses does not check ctx itself. func (d *Database) Vacuum(ctx context.Context) error { // Free up to 1000 pages per call (~4MB with default 4KB page size) // This keeps each vacuum operation quick const pagesToFree = 1000 d.lock("Vacuum") defer d.unlock() err := ctx.Err() if err != nil { return fmt.Errorf("failed to run incremental vacuum: %w", err) } rows, err := d.writer.QueryContext(ctx, fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pagesToFree)) if err != nil { return fmt.Errorf("failed to run incremental vacuum: %w", err) } defer func() { _ = rows.Close() }() // SQLite frees one page each time the statement steps, and each step // returns a row, so every row must be read for the pragma to free more // than one page. for rows.Next() { } err = rows.Err() if err != nil { return fmt.Errorf("failed to run incremental vacuum: %w", err) } return nil } // Analyze updates the query planner statistics. It runs ANALYZE, through // PRAGMA optimize, only on the tables that have an index without statistics or // whose size has changed tenfold since they were last analyzed: even with // analysisLimit, ANALYZE counts every entry of each index, so it takes longer // as the tables grow. It holds the database lock. func (d *Database) Analyze(ctx context.Context) error { d.lock("Analyze") defer d.unlock() err := ctx.Err() if err != nil { return fmt.Errorf("failed to analyze database: %w", err) } // analysis_limit is a setting of the connection, so it is set in the same // call as PRAGMA optimize, which runs both statements on one connection. _, err = d.writer.ExecContext(ctx, fmt.Sprintf( "PRAGMA analysis_limit=%d; PRAGMA optimize=%#x", analysisLimit, optimizeAllTables)) if err != nil { return fmt.Errorf("failed to analyze database: %w", err) } return nil } // Checkpoint runs a WAL checkpoint to transfer data from the WAL to the main database. // Uses TRUNCATE mode which blocks writers briefly but ensures complete checkpoint, // keeping the WAL small for fast read performance. It holds the database lock. func (d *Database) Checkpoint(ctx context.Context) error { d.lock("Checkpoint") defer d.unlock() err := ctx.Err() if err != nil { return fmt.Errorf("failed to checkpoint WAL: %w", err) } _, err = d.writer.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") if err != nil { return fmt.Errorf("failed to checkpoint WAL: %w", err) } return nil } // Ping checks if the database connection is alive with a lightweight query. func (d *Database) Ping(ctx context.Context) error { var result int err := d.db.QueryRowContext(ctx, "SELECT 1").Scan(&result) if err != nil { return fmt.Errorf("database ping failed: %w", err) } return nil } // The debug messages lock logs, with the operation's name, when it starts to // wait for the database mutex and when it holds it. const ( lockWaitMessage = "Acquiring database lock" lockHeldMessage = "Database lock acquired" ) // lock acquires the database mutex and logs debug information func (d *Database) lock(operation string) { // Get caller information _, file, line, _ := runtime.Caller(1) caller := fmt.Sprintf("%s:%d", filepath.Base(file), line) d.logger.Debug(lockWaitMessage, "operation", operation, "caller", caller) d.mu.Lock() d.lockedAt = time.Now() d.lockedBy = fmt.Sprintf("%s (%s)", operation, caller) d.logger.Debug(lockHeldMessage, "operation", operation, "caller", caller) } // unlock releases the database mutex and logs debug information including // hold duration func (d *Database) unlock() { holdDuration := time.Since(d.lockedAt) lockedBy := d.lockedBy d.lockedAt = time.Time{} d.lockedBy = "" d.mu.Unlock() d.logger.Debug("Database lock released", "held_by", lockedBy, "duration_ms", holdDuration.Milliseconds()) } // beginTx starts a new transaction on the writing connection, with logging func (d *Database) beginTx() (*loggingTx, error) { tx, err := d.writer.BeginTx(context.Background(), nil) if err != nil { return nil, err } return &loggingTx{Tx: tx, logger: d.logger}, nil } // rollback rolls back tx unless it has already been committed, and logs a // failure. Every write defers it right after beginning its transaction. func (d *Database) rollback(tx *loggingTx) { err := tx.Rollback() if err != nil && !errors.Is(err, sql.ErrTxDone) { d.logger.Error("Failed to rollback transaction", "error", err) } } // routeTimestampRange returns the earliest and latest last_updated across both // live route tables, or nil values when both tables are empty. Each query reads // one row from an end of the last_updated index rather than scanning the tables. func (d *Database) routeTimestampRange( ctx context.Context, ) (*time.Time, *time.Time, error) { var oldest, newest *time.Time oldestV4, ok, err := d.scanRouteTimestamp(ctx, "SELECT last_updated "+ "FROM live_routes_v4 ORDER BY last_updated ASC LIMIT 1") if err != nil { return nil, nil, err } if ok { oldest = &oldestV4 } oldestV6, ok, err := d.scanRouteTimestamp(ctx, "SELECT last_updated "+ "FROM live_routes_v6 ORDER BY last_updated ASC LIMIT 1") if err != nil { return nil, nil, err } if ok && (oldest == nil || oldestV6.Before(*oldest)) { oldest = &oldestV6 } newestV4, ok, err := d.scanRouteTimestamp(ctx, "SELECT last_updated "+ "FROM live_routes_v4 ORDER BY last_updated DESC LIMIT 1") if err != nil { return nil, nil, err } if ok { newest = &newestV4 } newestV6, ok, err := d.scanRouteTimestamp(ctx, "SELECT last_updated "+ "FROM live_routes_v6 ORDER BY last_updated DESC LIMIT 1") if err != nil { return nil, nil, err } if ok && (newest == nil || newestV6.After(*newest)) { newest = &newestV6 } return oldest, newest, nil } // scanRouteTimestamp runs a single-row timestamp query. The bool is false when // the table is empty. The query selects the last_updated column directly so the // driver parses the DATETIME value into a time.Time. func (d *Database) scanRouteTimestamp( ctx context.Context, query string, ) (time.Time, bool, error) { var ts time.Time err := d.db.QueryRowContext(ctx, query).Scan(&ts) switch { case errors.Is(err, sql.ErrNoRows): return time.Time{}, false, nil case err != nil: return time.Time{}, false, err default: return ts, true, nil } } // originPrefixes runs query, which selects the prefix, mask length and latest // last_updated of every prefix asn originates in the live route table of // ipVersion. Rows that fail to scan or whose timestamp does not parse are // logged and skipped. func (d *Database) originPrefixes( ctx context.Context, query string, asn, ipVersion int, ) ([]LiveRoute, error) { family := ipv4Family if ipVersion == ipVersionV6 { family = ipv6Family } rows, err := d.db.QueryContext(ctx, query, asn) if err != nil { return nil, fmt.Errorf("failed to query %s prefixes: %w", family, err) } defer func() { _ = rows.Close() }() var routes []LiveRoute for rows.Next() { var ( route LiveRoute lastUpdatedStr string ) err := rows.Scan(&route.Prefix, &route.MaskLength, &lastUpdatedStr) if err != nil { d.logger.Error("Failed to scan "+family+" prefix row", "error", err, "asn", asn) continue } // Parse the timestamp string route.LastUpdated, err = time.Parse("2006-01-02 15:04:05-07:00", lastUpdatedStr) if err != nil { // Try without timezone route.LastUpdated, err = time.Parse("2006-01-02 15:04:05", lastUpdatedStr) if err != nil { d.logger.Error("Failed to parse timestamp", "error", err, "timestamp", lastUpdatedStr) continue } } route.OriginASN = asn route.IPVersion = ipVersion routes = append(routes, route) } err = rows.Err() if err != nil { return nil, fmt.Errorf("failed to read %s prefixes: %w", family, err) } return routes, nil } // 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 }