53200cbc0d
Nuevas funciones Go con tests en tres dominios: - core: parse_cron_expr, next_cron_time, join_by_key, validate_struct_fields + tipo CronSchedule - datascience: pivot (tabla dinámica), diff_entities (comparación de entidades) - infra: http_get_json, http_post_json, http_download_file, cache_to_sqlite, cron_ticker Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
4.2 KiB
Go
157 lines
4.2 KiB
Go
package infra
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// SQLiteCache es un cache key-value persistido en SQLite con soporte de TTL.
|
|
// Valores almacenados como JSON serializado. El caller es responsable de
|
|
// deserializar el []byte retornado por Get.
|
|
// Seguro para uso concurrente.
|
|
type SQLiteCache struct {
|
|
db *sql.DB
|
|
namespace string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
const sqliteCacheSchema = `
|
|
CREATE TABLE IF NOT EXISTS cache (
|
|
namespace TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
expires_at REAL,
|
|
PRIMARY KEY (namespace, key)
|
|
);`
|
|
|
|
// CacheToSQLite abre (o crea) una base de datos SQLite en dbPath y retorna
|
|
// un SQLiteCache para el namespace dado.
|
|
func CacheToSQLite(dbPath, namespace string) (*SQLiteCache, error) {
|
|
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cache_to_sqlite: open db: %w", err)
|
|
}
|
|
if _, err := db.Exec(sqliteCacheSchema); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("cache_to_sqlite: create schema: %w", err)
|
|
}
|
|
return &SQLiteCache{db: db, namespace: namespace}, nil
|
|
}
|
|
|
|
// evictExpired elimina las entradas expiradas del namespace. Debe llamarse
|
|
// con el mutex ya tomado.
|
|
func (c *SQLiteCache) evictExpired() {
|
|
now := float64(time.Now().UnixNano()) / 1e9
|
|
c.db.Exec(
|
|
"DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?",
|
|
c.namespace, now,
|
|
)
|
|
}
|
|
|
|
// Get retorna el valor asociado a key y true, o nil y false si no existe o
|
|
// esta expirado. El []byte contiene JSON que el caller puede deserializar.
|
|
func (c *SQLiteCache) Get(key string) ([]byte, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.evictExpired()
|
|
var value string
|
|
err := c.db.QueryRow(
|
|
"SELECT value FROM cache WHERE namespace = ? AND key = ?",
|
|
c.namespace, key,
|
|
).Scan(&value)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return []byte(value), true
|
|
}
|
|
|
|
// Set almacena value (JSON bytes) bajo key. ttl=0 significa sin expiracion.
|
|
func (c *SQLiteCache) Set(key string, value []byte, ttl time.Duration) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
now := float64(time.Now().UnixNano()) / 1e9
|
|
var expiresAt any
|
|
if ttl > 0 {
|
|
expiresAt = now + ttl.Seconds()
|
|
}
|
|
_, err := c.db.Exec(
|
|
`INSERT INTO cache (namespace, key, value, created_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(namespace, key) DO UPDATE SET
|
|
value = excluded.value,
|
|
created_at = excluded.created_at,
|
|
expires_at = excluded.expires_at`,
|
|
c.namespace, key, string(value), now, expiresAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cache set: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete elimina la entrada asociada a key. Retorna error si falla la query.
|
|
func (c *SQLiteCache) Delete(key string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
_, err := c.db.Exec(
|
|
"DELETE FROM cache WHERE namespace = ? AND key = ?",
|
|
c.namespace, key,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("cache delete: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Clear elimina todas las entradas del namespace. Retorna el numero de filas
|
|
// eliminadas.
|
|
func (c *SQLiteCache) Clear() (int64, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
res, err := c.db.Exec(
|
|
"DELETE FROM cache WHERE namespace = ?",
|
|
c.namespace,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("cache clear: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n, nil
|
|
}
|
|
|
|
// GetOrSet retorna el valor cacheado o llama factory() para obtenerlo,
|
|
// lo almacena con el ttl dado y lo retorna.
|
|
func (c *SQLiteCache) GetOrSet(key string, factory func() ([]byte, error), ttl time.Duration) ([]byte, error) {
|
|
if v, ok := c.Get(key); ok {
|
|
return v, nil
|
|
}
|
|
value, err := factory()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cache get_or_set factory: %w", err)
|
|
}
|
|
if err := c.Set(key, value, ttl); err != nil {
|
|
return nil, err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// SetJSON serializa v como JSON y lo almacena bajo key.
|
|
func (c *SQLiteCache) SetJSON(key string, v any, ttl time.Duration) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("cache set_json marshal: %w", err)
|
|
}
|
|
return c.Set(key, b, ttl)
|
|
}
|
|
|
|
// Close cierra la conexion a la base de datos.
|
|
func (c *SQLiteCache) Close() error {
|
|
return c.db.Close()
|
|
}
|