cache: add SQLite route persistence; initial TTL and LRU eviction implementation
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0370d6c114d5490634905c1a831a31526a6a6964
This commit is contained in:
parent
9f264fbef1
commit
663f9995b2
8 changed files with 674 additions and 5 deletions
168
internal/cache/db.go
vendored
168
internal/cache/db.go
vendored
|
|
@ -1 +1,169 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Core routing decision persisted per store path.
|
||||
type RouteEntry struct {
|
||||
StorePath string
|
||||
UpstreamURL string
|
||||
LatencyMs float64
|
||||
LatencyEMA float64
|
||||
LastVerified time.Time
|
||||
QueryCount uint32
|
||||
FailureCount uint32
|
||||
TTL time.Time
|
||||
NarHash string
|
||||
NarSize uint64
|
||||
}
|
||||
|
||||
// Returns true if the entry exists and hasn't expired.
|
||||
func (r *RouteEntry) IsValid() bool {
|
||||
return r != nil && time.Now().Before(r.TTL)
|
||||
}
|
||||
|
||||
// SQLite-backed store for route persistence.
|
||||
type DB struct {
|
||||
db *sql.DB
|
||||
maxEntries int
|
||||
}
|
||||
|
||||
// Opens or creates the SQLite database at path with WAL mode.
|
||||
func Open(path string, maxEntries int) (*DB, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite WAL allows 1 writer
|
||||
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return &DB{db: db, maxEntries: maxEntries}, nil
|
||||
}
|
||||
|
||||
// Closes the database.
|
||||
func (d *DB) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
store_path TEXT PRIMARY KEY,
|
||||
upstream_url TEXT NOT NULL,
|
||||
latency_ms REAL DEFAULT 0,
|
||||
latency_ema REAL DEFAULT 0,
|
||||
query_count INTEGER DEFAULT 1,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
last_verified INTEGER DEFAULT 0,
|
||||
ttl INTEGER NOT NULL,
|
||||
nar_hash TEXT DEFAULT '',
|
||||
nar_size INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_ttl ON routes(ttl);
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_last_verified ON routes(last_verified);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS upstream_health (
|
||||
url TEXT PRIMARY KEY,
|
||||
ema_latency REAL DEFAULT 0,
|
||||
last_probe INTEGER DEFAULT 0,
|
||||
consecutive_fails INTEGER DEFAULT 0,
|
||||
total_queries INTEGER DEFAULT 0,
|
||||
success_rate REAL DEFAULT 1.0
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Returns the route for storePath, or nil if not found.
|
||||
func (d *DB) GetRoute(storePath string) (*RouteEntry, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT store_path, upstream_url, latency_ms, latency_ema,
|
||||
query_count, failure_count, last_verified, ttl, nar_hash, nar_size
|
||||
FROM routes WHERE store_path = ?`, storePath)
|
||||
|
||||
var e RouteEntry
|
||||
var lastVerifiedUnix, ttlUnix int64
|
||||
err := row.Scan(
|
||||
&e.StorePath, &e.UpstreamURL, &e.LatencyMs, &e.LatencyEMA,
|
||||
&e.QueryCount, &e.FailureCount, &lastVerifiedUnix, &ttlUnix,
|
||||
&e.NarHash, &e.NarSize,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.LastVerified = time.Unix(lastVerifiedUnix, 0).UTC()
|
||||
e.TTL = time.Unix(ttlUnix, 0).UTC()
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Inserts or updates a route entry.
|
||||
func (d *DB) SetRoute(entry *RouteEntry) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO routes
|
||||
(store_path, upstream_url, latency_ms, latency_ema,
|
||||
query_count, failure_count, last_verified, ttl, nar_hash, nar_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(store_path) DO UPDATE SET
|
||||
upstream_url = excluded.upstream_url,
|
||||
latency_ms = excluded.latency_ms,
|
||||
latency_ema = excluded.latency_ema,
|
||||
query_count = excluded.query_count,
|
||||
failure_count = excluded.failure_count,
|
||||
last_verified = excluded.last_verified,
|
||||
ttl = excluded.ttl,
|
||||
nar_hash = excluded.nar_hash,
|
||||
nar_size = excluded.nar_size`,
|
||||
entry.StorePath, entry.UpstreamURL,
|
||||
entry.LatencyMs, entry.LatencyEMA,
|
||||
entry.QueryCount, entry.FailureCount,
|
||||
entry.LastVerified.Unix(), entry.TTL.Unix(),
|
||||
entry.NarHash, entry.NarSize,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.evictIfNeeded()
|
||||
}
|
||||
|
||||
// Deletes routes whose TTL has passed.
|
||||
func (d *DB) ExpireOldRoutes() error {
|
||||
_, err := d.db.Exec(`DELETE FROM routes WHERE ttl < ?`, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Returns the total number of stored routes.
|
||||
func (d *DB) RouteCount() (int, error) {
|
||||
var count int
|
||||
err := d.db.QueryRow(`SELECT COUNT(*) FROM routes`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Deletes the oldest routes (by last_verified) when over capacity.
|
||||
func (d *DB) evictIfNeeded() error {
|
||||
count, err := d.RouteCount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count <= d.maxEntries {
|
||||
return nil
|
||||
}
|
||||
excess := count - d.maxEntries
|
||||
_, err = d.db.Exec(`
|
||||
DELETE FROM routes WHERE store_path IN (
|
||||
SELECT store_path FROM routes ORDER BY last_verified ASC LIMIT ?
|
||||
)`, excess)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue