first
This commit is contained in:
326
backend/internal/storage/storage.go
Normal file
326
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string
|
||||
Topic string
|
||||
Payload string
|
||||
QOS byte
|
||||
Retained bool
|
||||
Timestamp time.Time
|
||||
Size int
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Count int64 `json:"count"`
|
||||
Size string `json:"size"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creation dossier sqlite: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture sqlite: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("pragma WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil {
|
||||
return nil, fmt.Errorf("pragma synchronous: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{db: db, path: path}
|
||||
if err := store.initSchema(); err != nil {
|
||||
if isCorruptErr(err) {
|
||||
_ = db.Close()
|
||||
return recoverCorruptDB(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := store.integrityCheck(); err != nil {
|
||||
if isCorruptErr(err) {
|
||||
_ = db.Close()
|
||||
return recoverCorruptDB(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
qos INTEGER NOT NULL,
|
||||
retained INTEGER NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_topic_ts ON messages(topic, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
|
||||
`
|
||||
_, err := s.db.Exec(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema sqlite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) integrityCheck() error {
|
||||
var result string
|
||||
if err := s.db.QueryRow("PRAGMA integrity_check;").Scan(&result); err != nil {
|
||||
return fmt.Errorf("integrity check: %w", err)
|
||||
}
|
||||
if strings.ToLower(result) != "ok" {
|
||||
return fmt.Errorf("integrity check: %s", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recoverCorruptDB(path string) (*Store, error) {
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
backupWithSuffix(path, suffix)
|
||||
backupWithSuffix(path+"-wal", suffix)
|
||||
backupWithSuffix(path+"-shm", suffix)
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture sqlite apres recovery: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma synchronous: %w", err)
|
||||
}
|
||||
store := &Store{db: db, path: path}
|
||||
if err := store.initSchema(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func backupWithSuffix(path, suffix string) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(path, fmt.Sprintf("%s.corrupt-%s", path, suffix))
|
||||
}
|
||||
|
||||
func isCorruptErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "database disk image is malformed") ||
|
||||
strings.Contains(msg, "malformed") ||
|
||||
strings.Contains(msg, "btreeinitpage") ||
|
||||
strings.Contains(msg, "error code 11")
|
||||
}
|
||||
|
||||
func (s *Store) InsertMessage(msg Message) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO messages(id, topic, payload, qos, retained, ts, size) VALUES(?,?,?,?,?,?,?)`,
|
||||
msg.ID,
|
||||
msg.Topic,
|
||||
msg.Payload,
|
||||
int(msg.QOS),
|
||||
boolToInt(msg.Retained),
|
||||
msg.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
msg.Size,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetHistory(topic string, limit int, from, to string) ([]Message, error) {
|
||||
args := []any{topic}
|
||||
query := "SELECT id, topic, payload, qos, retained, ts, size FROM messages WHERE topic = ?"
|
||||
if from != "" {
|
||||
query += " AND ts >= ?"
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
query += " AND ts <= ?"
|
||||
args = append(args, to)
|
||||
}
|
||||
query += " ORDER BY ts DESC"
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture historique: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var msg Message
|
||||
var retained int
|
||||
var ts string
|
||||
if err := rows.Scan(&msg.ID, &msg.Topic, &msg.Payload, &msg.QOS, &retained, &ts, &msg.Size); err != nil {
|
||||
return nil, fmt.Errorf("scan historique: %w", err)
|
||||
}
|
||||
msg.Retained = retained == 1
|
||||
parsed, _ := time.Parse(time.RFC3339Nano, ts)
|
||||
msg.Timestamp = parsed
|
||||
out = append(out, msg)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearTopicHistory(topic string) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE topic = ?", topic)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suppression topic: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearAllHistory() (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suppression db: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if err := s.Compact(); err != nil {
|
||||
return affected, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) PurgeOversize(maxBytes int) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE size >= ?", maxBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge oversize: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOldestFraction(fraction float64) (int64, error) {
|
||||
if fraction <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM messages").Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count messages: %w", err)
|
||||
}
|
||||
limit := int64(math.Round(float64(count) * fraction))
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE id IN (SELECT id FROM messages ORDER BY ts ASC LIMIT ?)", limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge oldest fraction: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) PurgeBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE ts < ?", cutoff.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge ttl: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) Stats() (Stats, error) {
|
||||
var count int64
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM messages").Scan(&count); err != nil {
|
||||
return Stats{}, fmt.Errorf("stats count: %w", err)
|
||||
}
|
||||
sizeBytes := s.totalSize()
|
||||
return Stats{
|
||||
Count: count,
|
||||
Size: formatBytes(sizeBytes),
|
||||
Bytes: sizeBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) totalSize() int64 {
|
||||
if s.path == "" {
|
||||
return 0
|
||||
}
|
||||
total := fileSize(s.path)
|
||||
total += fileSize(s.path + "-wal")
|
||||
total += fileSize(s.path + "-shm")
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *Store) Compact() error {
|
||||
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);"); err != nil {
|
||||
return fmt.Errorf("checkpoint wal: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec("VACUUM;"); err != nil {
|
||||
return fmt.Errorf("vacuum: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func formatBytes(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
kb := float64(size) / 1024
|
||||
if kb < 1024 {
|
||||
return fmt.Sprintf("%.1f KB", kb)
|
||||
}
|
||||
mb := kb / 1024
|
||||
if mb < 1024 {
|
||||
return fmt.Sprintf("%.1f MB", mb)
|
||||
}
|
||||
gb := mb / 1024
|
||||
return fmt.Sprintf("%.2f GB", gb)
|
||||
}
|
||||
|
||||
func boolToInt(val bool) int {
|
||||
if val {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user