Release v1.0.0 #major
release / goreleaser (push) Failing after 51s

This commit is contained in:
2026-06-02 23:12:36 -05:00
parent 276981a7df
commit d5e65fbb03
17 changed files with 3447 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
// Package recording handles persistence (SQLite), capture (Recorder), and
// playback (Player) of UDP telemetry frames.
package recording
import (
"database/sql"
"fmt"
"log/slog"
"os"
"time"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
)
// Info is the JSON shape returned by /api/recordings.
type Info struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
Frames int `json:"frames"`
DurationMs int64 `json:"duration_ms"`
}
// OpenDB opens (or creates) the SQLite database and ensures the schema is
// up-to-date, performing any necessary additive migrations.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", path+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
db.SetMaxOpenConns(4)
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
frames INTEGER DEFAULT 0,
duration_ms INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recording_id INTEGER NOT NULL,
offset_ms INTEGER NOT NULL,
data BLOB NOT NULL,
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_frames_recording ON frames(recording_id, offset_ms);
CREATE INDEX IF NOT EXISTS idx_recordings_created_at ON recordings(created_at);
`); err != nil {
return nil, fmt.Errorf("create tables: %w", err)
}
if !columnExists(db, "frames", "codec") {
if _, err := db.Exec(`ALTER TABLE frames ADD COLUMN codec INTEGER NOT NULL DEFAULT 0`); err != nil {
return nil, fmt.Errorf("add codec column: %w", err)
}
slog.Info("migration: added frames.codec column")
}
return db, nil
}
func columnExists(db *sql.DB, table, col string) bool {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
continue
}
if name == col {
return true
}
}
return false
}
// List returns recordings ordered newest-first.
// since: optional RFC3339 timestamp lower bound (empty disables).
// limit: max rows (0 = no limit).
func List(db *sql.DB, since string, limit int) ([]Info, error) {
q := `SELECT id, name, created_at, frames, duration_ms FROM recordings`
args := []any{}
if since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
q += ` WHERE created_at >= ?`
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
}
}
q += ` ORDER BY created_at DESC`
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Info{}
for rows.Next() {
var r Info
if err := rows.Scan(&r.ID, &r.Name, &r.CreatedAt, &r.Frames, &r.DurationMs); err != nil {
continue
}
out = append(out, r)
}
return out, nil
}
// Delete removes a recording by ID. CASCADE drops associated frames.
// Returns the rows affected.
func Delete(db *sql.DB, id int64) (int64, error) {
res, err := db.Exec(`DELETE FROM recordings WHERE id = ?`, id)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// CleanOld removes recordings older than retention and emits deletion events
// to the hub.
func CleanOld(db *sql.DB, h *hub.Hub, retention time.Duration) {
cutoff := time.Now().Add(-retention).UTC().Format("2006-01-02 15:04:05")
rows, err := db.Query(`SELECT id FROM recordings WHERE created_at < ?`, cutoff)
if err != nil {
slog.Error("cleanup query error", "err", err)
return
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
rows.Close()
if len(ids) == 0 {
return
}
if _, err := db.Exec(`DELETE FROM recordings WHERE created_at < ?`, cutoff); err != nil {
slog.Error("cleanup error", "err", err)
return
}
slog.Info("cleaned old recordings", "n", len(ids))
for _, id := range ids {
h.PublishEvent(map[string]any{
"type": "event", "kind": "recording_deleted",
"recording": map[string]any{"id": id},
})
}
}
// DBSize returns the size of the database file in bytes, or 0 if it cannot
// be stat'd.
func DBSize(path string) int64 {
if fi, err := os.Stat(path); err == nil {
return fi.Size()
}
return 0
}
+342
View File
@@ -0,0 +1,342 @@
package recording
import (
"database/sql"
"encoding/json"
"log/slog"
"strconv"
"sync"
"time"
"github.com/gofiber/websocket/v2"
"github.com/klauspost/compress/zstd"
)
// PlayerCmd is a client→server playback control message.
type PlayerCmd struct {
Cmd string `json:"cmd"`
Ms int64 `json:"ms,omitempty"`
Value float64 `json:"value,omitempty"`
}
// Player streams a single recording over a websocket with seek/pause/speed
// controls. Frames are streamed from SQLite in a sliding window so memory use
// is bounded.
type Player struct {
db *sql.DB
conn *websocket.Conn
recordingID int64
totalFrames int
durationMs int64
dec *zstd.Decoder
mu sync.Mutex
speed float64
playing bool
positionMs int64
cmdCh chan PlayerCmd
stopCh chan struct{}
}
// NewPlayer constructs a Player bound to a websocket connection.
func NewPlayer(db *sql.DB, conn *websocket.Conn, id int64) (*Player, error) {
dec, err := zstd.NewReader(nil)
if err != nil {
return nil, err
}
return &Player{
db: db,
conn: conn,
recordingID: id,
dec: dec,
speed: 1.0,
playing: true,
cmdCh: make(chan PlayerCmd, 16),
stopCh: make(chan struct{}),
}, nil
}
func (p *Player) sendJSON(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return p.conn.WriteMessage(websocket.TextMessage, b)
}
func (p *Player) sendErr(msg string) { _ = p.sendJSON(map[string]any{"error": msg}) }
func (p *Player) sendState() {
p.mu.Lock()
playing, speed, pos := p.playing, p.speed, p.positionMs
p.mu.Unlock()
_ = p.sendJSON(map[string]any{
"type": "playback_state", "playing": playing, "speed": speed, "position_ms": pos,
})
}
// Run executes the playback loop. It returns when playback ends, the client
// stops it, or the connection drops.
func (p *Player) Run() {
defer p.dec.Close()
var name string
if err := p.db.QueryRow(`SELECT name FROM recordings WHERE id = ?`, p.recordingID).Scan(&name); err != nil {
p.sendErr("recording not found")
return
}
if err := p.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(offset_ms),0) FROM frames WHERE recording_id = ?`, p.recordingID).
Scan(&p.totalFrames, &p.durationMs); err != nil {
p.sendErr("failed to read recording")
return
}
if p.totalFrames == 0 {
p.sendErr("no frames in recording")
return
}
_ = p.sendJSON(map[string]any{
"type": "playback_start", "name": name,
"frames": p.totalFrames, "duration_ms": p.durationMs,
})
go p.readCommands()
const prefetch = 256
type frame struct {
ms int64
data []byte
codec int
}
var buf []frame
cursor := int64(-1)
frameIdx := 0
refill := func(fromMs int64) error {
buf = buf[:0]
rows, err := p.db.Query(
`SELECT offset_ms, data, codec FROM frames WHERE recording_id = ? AND offset_ms >= ? ORDER BY offset_ms LIMIT ?`,
p.recordingID, fromMs, prefetch)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var f frame
if err := rows.Scan(&f.ms, &f.data, &f.codec); err != nil {
continue
}
buf = append(buf, f)
}
return nil
}
doSeek := func(targetMs int64) {
if targetMs < 0 {
targetMs = 0
}
if targetMs > p.durationMs {
targetMs = p.durationMs
}
p.mu.Lock()
p.positionMs = targetMs
p.mu.Unlock()
cursor = targetMs - 1
buf = buf[:0]
var idx int
if err := p.db.QueryRow(
`SELECT COUNT(*) FROM frames WHERE recording_id = ? AND offset_ms < ?`,
p.recordingID, targetMs).Scan(&idx); err == nil {
frameIdx = idx
}
p.sendState()
}
if err := refill(0); err != nil {
p.sendErr("read error")
return
}
p.sendState()
anchorWall := time.Now()
anchorPos := int64(0)
timer := time.NewTimer(time.Hour)
defer timer.Stop()
armTimer := func() {
p.mu.Lock()
playing, speed, pos := p.playing, p.speed, p.positionMs
p.mu.Unlock()
if !playing || len(buf) == 0 {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(time.Hour)
return
}
next := buf[0]
delta := time.Duration(float64(next.ms-pos)/speed) * time.Millisecond
if delta < 0 {
delta = 0
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(delta)
}
armTimer()
stateBroadcast := time.NewTicker(250 * time.Millisecond)
defer stateBroadcast.Stop()
for {
select {
case <-p.stopCh:
return
case cmd := <-p.cmdCh:
switch cmd.Cmd {
case "play":
p.mu.Lock()
if !p.playing {
p.playing = true
anchorWall = time.Now()
anchorPos = p.positionMs
}
p.mu.Unlock()
p.sendState()
armTimer()
case "pause":
p.mu.Lock()
if p.playing {
elapsed := time.Since(anchorWall)
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
if p.positionMs > p.durationMs {
p.positionMs = p.durationMs
}
p.playing = false
}
p.mu.Unlock()
p.sendState()
armTimer()
case "speed":
p.mu.Lock()
if cmd.Value > 0 && cmd.Value <= 16 {
if p.playing {
elapsed := time.Since(anchorWall)
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
}
p.speed = cmd.Value
anchorWall = time.Now()
anchorPos = p.positionMs
}
p.mu.Unlock()
p.sendState()
armTimer()
case "seek":
doSeek(cmd.Ms)
if err := refill(cmd.Ms); err != nil {
p.sendErr("read error")
return
}
p.mu.Lock()
anchorWall = time.Now()
anchorPos = p.positionMs
p.mu.Unlock()
armTimer()
case "stop":
return
}
case <-stateBroadcast.C:
p.mu.Lock()
if p.playing {
elapsed := time.Since(anchorWall)
p.positionMs = anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
if p.positionMs > p.durationMs {
p.positionMs = p.durationMs
}
}
p.mu.Unlock()
p.sendState()
case <-timer.C:
p.mu.Lock()
if !p.playing {
p.mu.Unlock()
armTimer()
continue
}
elapsed := time.Since(anchorWall)
pos := anchorPos + int64(float64(elapsed.Milliseconds())*p.speed)
p.positionMs = pos
p.mu.Unlock()
for len(buf) > 0 && buf[0].ms <= p.positionMs {
f := buf[0]
buf = buf[1:]
data := f.data
if f.codec == 1 {
out, err := p.dec.DecodeAll(f.data, nil)
if err != nil {
slog.Warn("frame decode failed", "err", err)
continue
}
data = out
}
frameIdx++
msg := append([]byte(nil), `{"type":"playback_frame","offset_ms":`...)
msg = strconv.AppendInt(msg, f.ms, 10)
msg = append(msg, `,"frame":`...)
msg = strconv.AppendInt(msg, int64(frameIdx), 10)
msg = append(msg, `,"data":`...)
msg = append(msg, data...)
msg = append(msg, '}')
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
cursor = f.ms
}
if len(buf) == 0 {
if err := refill(cursor + 1); err != nil {
p.sendErr("read error")
return
}
if len(buf) == 0 {
_ = p.sendJSON(map[string]any{"type": "playback_end"})
return
}
}
armTimer()
}
}
}
func (p *Player) readCommands() {
for {
_, msg, err := p.conn.ReadMessage()
if err != nil {
close(p.stopCh)
return
}
var cmd PlayerCmd
if err := json.Unmarshal(msg, &cmd); err != nil {
continue
}
select {
case p.cmdCh <- cmd:
default:
}
}
}
+238
View File
@@ -0,0 +1,238 @@
package recording
import (
"database/sql"
"log/slog"
"time"
"github.com/klauspost/compress/zstd"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub"
"git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats"
)
const (
flushBatchSize = 64
flushInterval = 100 * time.Millisecond
framesChanCap = 1024
idleTimeout = 5 * time.Second
)
type rawFrame struct {
t time.Time
data []byte
}
// Recorder receives raw UDP frames and persists them in batched, optionally
// zstd-compressed inserts to SQLite. A single writer goroutine owns the DB
// handles so there is no contention on the UDP hot path.
type Recorder struct {
db *sql.DB
hub *hub.Hub
frames chan rawFrame
compress bool
enc *zstd.Encoder
// session bookkeeping (writer-goroutine-owned)
sessionID int64
sessionName string
startTime time.Time
frameCount int
lastFrameAt time.Time
insertFrame *sql.Stmt
insertSession *sql.Stmt
updateSession *sql.Stmt
doneCh chan struct{}
stoppedCh chan struct{}
}
// NewRecorder constructs a Recorder. Call Start to launch the writer goroutine.
func NewRecorder(db *sql.DB, h *hub.Hub, compress bool) (*Recorder, error) {
r := &Recorder{
db: db,
hub: h,
frames: make(chan rawFrame, framesChanCap),
compress: compress,
doneCh: make(chan struct{}),
stoppedCh: make(chan struct{}),
}
var err error
if r.insertFrame, err = db.Prepare(`INSERT INTO frames(recording_id, offset_ms, data, codec) VALUES(?,?,?,?)`); err != nil {
return nil, err
}
if r.insertSession, err = db.Prepare(`INSERT INTO recordings(name) VALUES(?)`); err != nil {
return nil, err
}
if r.updateSession, err = db.Prepare(`UPDATE recordings SET frames=?, duration_ms=? WHERE id=?`); err != nil {
return nil, err
}
if compress {
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return nil, err
}
r.enc = enc
}
return r, nil
}
// Submit hands a UDP packet to the recorder. Non-blocking: if the queue is
// full the frame is dropped and counted in stats.FramesDropped.
func (r *Recorder) Submit(data []byte) {
cp := make([]byte, len(data))
copy(cp, data)
select {
case r.frames <- rawFrame{t: time.Now(), data: cp}:
default:
stats.Global.FramesDropped.Add(1)
}
}
// Start launches the writer goroutine. Stop must be called to flush.
func (r *Recorder) Start() { go r.run() }
// Stop signals the writer to drain the queue and exit, then waits for it.
func (r *Recorder) Stop() {
close(r.doneCh)
<-r.stoppedCh
}
func (r *Recorder) run() {
defer close(r.stoppedCh)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
idleCheck := time.NewTicker(idleTimeout / 2)
defer idleCheck.Stop()
batch := make([]rawFrame, 0, flushBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
if err := r.writeBatch(batch); err != nil {
slog.Error("flush batch failed", "err", err, "n", len(batch))
}
batch = batch[:0]
}
for {
select {
case <-r.doneCh:
drain:
for {
select {
case f := <-r.frames:
batch = append(batch, f)
if len(batch) >= flushBatchSize {
flush()
}
default:
break drain
}
}
flush()
r.finalizeSession()
return
case f := <-r.frames:
batch = append(batch, f)
if len(batch) >= flushBatchSize {
flush()
}
case <-ticker.C:
flush()
case <-idleCheck.C:
if r.sessionID != 0 && time.Since(r.lastFrameAt) > idleTimeout {
flush()
r.finalizeSession()
}
}
}
}
// writeBatch persists frames within a single transaction. If no session is
// active, one is started using the first frame's timestamp.
func (r *Recorder) writeBatch(batch []rawFrame) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
if r.sessionID == 0 {
first := batch[0].t
r.sessionName = first.Format("2006-01-02_15-04-05")
res, err := tx.Stmt(r.insertSession).Exec(r.sessionName)
if err != nil {
_ = tx.Rollback()
return err
}
r.sessionID, _ = res.LastInsertId()
r.startTime = first
r.frameCount = 0
slog.Info("recording started", "name", r.sessionName, "id", r.sessionID)
r.hub.PublishEvent(map[string]any{
"type": "event", "kind": "recording_started",
"recording": map[string]any{"id": r.sessionID, "name": r.sessionName},
})
}
stmt := tx.Stmt(r.insertFrame)
for _, f := range batch {
payload := f.data
codec := 0
stats.Global.BytesBeforeCompr.Add(int64(len(payload)))
if r.compress && r.enc != nil {
payload = r.enc.EncodeAll(f.data, make([]byte, 0, len(f.data)/2+64))
codec = 1
}
stats.Global.BytesAfterCompr.Add(int64(len(payload)))
offset := f.t.Sub(r.startTime).Milliseconds()
if _, err := stmt.Exec(r.sessionID, offset, payload, codec); err != nil {
_ = tx.Rollback()
return err
}
r.frameCount++
r.lastFrameAt = f.t
stats.Global.FramesWritten.Add(1)
}
return tx.Commit()
}
func (r *Recorder) finalizeSession() {
if r.sessionID == 0 {
return
}
duration := r.lastFrameAt.Sub(r.startTime).Milliseconds()
if _, err := r.updateSession.Exec(r.frameCount, duration, r.sessionID); err != nil {
slog.Error("finalize session failed", "err", err)
}
slog.Info("recording stopped", "name", r.sessionName, "frames", r.frameCount, "duration_ms", duration)
r.hub.PublishEvent(map[string]any{
"type": "event", "kind": "recording_stopped",
"recording": map[string]any{
"id": r.sessionID, "name": r.sessionName,
"frames": r.frameCount, "duration_ms": duration,
},
})
r.sessionID = 0
r.sessionName = ""
r.frameCount = 0
}
// CurrentSession returns a snapshot of the active recording, or zero values
// if none. Reads are not strictly synchronized; slight staleness is acceptable
// for the /api/stats endpoint.
func (r *Recorder) CurrentSession() (id int64, name string, frames int, elapsedMs int64) {
id = r.sessionID
name = r.sessionName
frames = r.frameCount
if id != 0 {
elapsedMs = time.Since(r.startTime).Milliseconds()
}
return
}