check / check (push) Successful in 1m3s
The routing table records a row each time a prefix and origin AS gets its first peer (announced) or loses its last (withdrawn), with the time and how many peers carry the prefix just after. The prefix handler writes the rows to a new origin_changes table, apart from routes. Every hour the database maintainer keeps each prefix's rows from the last 72 hours or its newest 500, whichever keeps more. The prefix page lists them newest first, at most 5,000. At most 100,000 rows wait in memory to be written; past that the oldest are dropped and logged. A stop loses the rows past its last write. Model: deepseek-v4-pro (implementation); opus-5-5 (commit message)
173 lines
4.6 KiB
Go
173 lines
4.6 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
// minOriginChangesPerPrefix is how many of a prefix's newest origin changes
|
|
// the prune always keeps, whatever their age.
|
|
const minOriginChangesPerPrefix = 500
|
|
|
|
// originChangesRetention is how long the prune keeps an origin change: rows
|
|
// older than this are deleted, unless they are among the prefix's newest
|
|
// minOriginChangesPerPrefix.
|
|
const originChangesRetention = 72 * time.Hour
|
|
|
|
// The statements of the origin_changes table (schema.sql).
|
|
const (
|
|
insertOriginChangeSQL = `INSERT INTO origin_changes
|
|
(prefix, origin_asn, announced, peer_count, time)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
originChangesSQL = `SELECT prefix, origin_asn, announced, peer_count, time
|
|
FROM origin_changes WHERE prefix = ? ORDER BY id DESC LIMIT ?`
|
|
prunePrefixesSQL = `SELECT prefix FROM origin_changes
|
|
GROUP BY prefix HAVING COUNT(*) > ?`
|
|
pruneOriginChangesSQL = `DELETE FROM origin_changes
|
|
WHERE prefix = ? AND time < ? AND id < (
|
|
SELECT id FROM origin_changes WHERE prefix = ?
|
|
ORDER BY id DESC LIMIT 1 OFFSET ?)`
|
|
)
|
|
|
|
// WriteOriginChanges writes, in one transaction, the origin changes the
|
|
// routing table handed over. When it fails, it writes nothing; the caller then
|
|
// writes them again.
|
|
func (d *Database) WriteOriginChanges(changes []routetable.OriginChange) error {
|
|
if len(changes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
d.lock("WriteOriginChanges")
|
|
defer d.unlock()
|
|
|
|
tx, err := d.beginTx()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer d.rollback(tx)
|
|
|
|
ctx := context.Background()
|
|
|
|
stmt, err := tx.PrepareContext(ctx, insertOriginChangeSQL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prepare origin change insert: %w", err)
|
|
}
|
|
defer func() { _ = stmt.Close() }()
|
|
|
|
for _, change := range changes {
|
|
_, err := stmt.ExecContext(ctx, change.Prefix.String(),
|
|
change.OriginASN, change.Announced, change.PeerCount,
|
|
change.Time.Unix())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write origin change %s: %w",
|
|
change.Prefix, err)
|
|
}
|
|
}
|
|
|
|
err = tx.Commit()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetOriginChanges returns at most limit of prefix's origin changes, newest
|
|
// first.
|
|
func (d *Database) GetOriginChanges(
|
|
ctx context.Context, prefix netip.Prefix, limit int,
|
|
) ([]routetable.OriginChange, error) {
|
|
rows, err := d.db.QueryContext(ctx, originChangesSQL,
|
|
prefix.Masked().String(), limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read origin changes: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var changes []routetable.OriginChange
|
|
|
|
for rows.Next() {
|
|
var (
|
|
change routetable.OriginChange
|
|
prefixText string
|
|
timeSec int64
|
|
)
|
|
|
|
err := rows.Scan(&prefixText, &change.OriginASN, &change.Announced,
|
|
&change.PeerCount, &timeSec)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read an origin change: %w", err)
|
|
}
|
|
|
|
change.Prefix, err = netip.ParsePrefix(prefixText)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read origin change %s: %w",
|
|
prefixText, err)
|
|
}
|
|
|
|
change.Time = time.Unix(timeSec, 0).UTC()
|
|
changes = append(changes, change)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read origin changes: %w", err)
|
|
}
|
|
|
|
return changes, nil
|
|
}
|
|
|
|
// PruneOriginChanges deletes, for each prefix with more than
|
|
// minOriginChangesPerPrefix rows, the rows older than originChangesRetention
|
|
// that are not among its newest minOriginChangesPerPrefix. It finds those
|
|
// prefixes on a reading connection and deletes one prefix per write, so no
|
|
// write holds the lock for long.
|
|
func (d *Database) PruneOriginChanges(ctx context.Context) error {
|
|
rows, err := d.db.QueryContext(ctx, prunePrefixesSQL,
|
|
minOriginChangesPerPrefix)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find prefixes to prune: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var prefixes []string
|
|
|
|
for rows.Next() {
|
|
var prefix string
|
|
|
|
err := rows.Scan(&prefix)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read a prefix to prune: %w", err)
|
|
}
|
|
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find prefixes to prune: %w", err)
|
|
}
|
|
|
|
cutoff := time.Now().Add(-originChangesRetention).Unix()
|
|
|
|
for _, prefix := range prefixes {
|
|
d.lock("PruneOriginChanges")
|
|
|
|
_, err := d.writer.ExecContext(ctx, pruneOriginChangesSQL,
|
|
prefix, cutoff, prefix, minOriginChangesPerPrefix-1)
|
|
|
|
d.unlock()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prune origin changes of %s: %w",
|
|
prefix, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|