feat(internal): add NATS abstraction and DB connection manager
- internal/nats: Connection interface + InProcessConn, 7 tests - internal/db: PostgreSQL connection pool + migration runner, 5 tests - 75 total tests, build + vet clean
This commit is contained in:
1 parent
750d594055
commit
ca106f71ae
7 files changed
+481
No files matched your search
@@ -3,3 +3,5 @@ module git.yeij.top/AskaEth/Cyrene_v2
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/joho/godotenv v1.5.1
|
require github.com/joho/godotenv v1.5.1
|
||||||
|
|
||||||
|
require github.com/lib/pq v1.12.3
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Package db provides database connection management, migration support, and helper utilities.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq" // PostgreSQL driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds database connection parameters.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DBName string
|
||||||
|
SSLMode string
|
||||||
|
URL string // Full DSN takes priority over other fields
|
||||||
|
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "cyrene",
|
||||||
|
Password: "cyrene_pass",
|
||||||
|
DBName: "cyrene_ai",
|
||||||
|
SSLMode: "disable",
|
||||||
|
|
||||||
|
MaxOpenConns: 25,
|
||||||
|
MaxIdleConns: 5,
|
||||||
|
ConnMaxLifetime: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes a PostgreSQL connection pool.
|
||||||
|
func Connect(cfg Config) (*sql.DB, error) {
|
||||||
|
dsn := cfg.URL
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||||
|
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
|
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
|
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[db] connected to %s:%d/%s (max_open=%d, max_idle=%d)",
|
||||||
|
cfg.Host, cfg.Port, cfg.DBName, cfg.MaxOpenConns, cfg.MaxIdleConns)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustConnect is like Connect but panics on error.
|
||||||
|
// Use only in main() where startup failure is unrecoverable.
|
||||||
|
func MustConnect(cfg Config) *sql.DB {
|
||||||
|
db, err := Connect(cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to connect to database: %v", err))
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck verifies the database connection is alive.
|
||||||
|
func HealthCheck(ctx context.Context, db *sql.DB) error {
|
||||||
|
return db.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction executes fn within a database transaction.
|
||||||
|
// If fn returns an error, the transaction is rolled back.
|
||||||
|
func Transaction(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
panic(p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
return fmt.Errorf("rollback failed (original: %w): %v", err, rbErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigration executes a list of SQL migration statements in order.
|
||||||
|
// Each migration is run within its own transaction.
|
||||||
|
func RunMigration(ctx context.Context, db *sql.DB, name string, statements []string) error {
|
||||||
|
log.Printf("[db] running migration: %s (%d statements)", name, len(statements))
|
||||||
|
|
||||||
|
for i, stmt := range statements {
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("migration %s[%d]: %w", name, i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[db] migration %s completed", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Host != "localhost" {
|
||||||
|
t.Errorf("expected host localhost, got %s", cfg.Host)
|
||||||
|
}
|
||||||
|
if cfg.Port != 5432 {
|
||||||
|
t.Errorf("expected port 5432, got %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.User != "cyrene" {
|
||||||
|
t.Errorf("expected user cyrene, got %s", cfg.User)
|
||||||
|
}
|
||||||
|
if cfg.MaxOpenConns != 25 {
|
||||||
|
t.Errorf("expected MaxOpenConns=25, got %d", cfg.MaxOpenConns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDSN(t *testing.T) {
|
||||||
|
// Test that URL takes priority
|
||||||
|
cfgURL := DefaultConfig()
|
||||||
|
cfgURL.URL = "postgres://custom:pass@remote:9999/mydb?sslmode=require"
|
||||||
|
|
||||||
|
db, err := Connect(cfgURL)
|
||||||
|
// Connection will fail but DSN construction should work
|
||||||
|
if err == nil {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
// Just verify it doesn't panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectTimeout(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.Host = "non-existent-host.invalid"
|
||||||
|
cfg.Port = 5432
|
||||||
|
|
||||||
|
_, err := Connect(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected connection error for invalid host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactionRollback(t *testing.T) {
|
||||||
|
// This test verifies the rollback logic without a real DB
|
||||||
|
// by testing the function signature and error handling path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnMaxLifetimeDefault(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.ConnMaxLifetime != 5*time.Minute {
|
||||||
|
t.Errorf("expected ConnMaxLifetime=5m, got %v", cfg.ConnMaxLifetime)
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,155 @@
|
|||||||
|
// Package nats provides an abstraction layer for NATS messaging.
|
||||||
|
// It defines interfaces for pub/sub and request-reply patterns,
|
||||||
|
// allowing the real nats.go client or in-process mocks to be used interchangeably.
|
||||||
|
package nats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connection is the core abstraction for NATS communication.
|
||||||
|
// Backed by a real NATS connection in production, or an in-process stub in development.
|
||||||
|
type Connection interface {
|
||||||
|
// Publish sends a message to the given subject asynchronously.
|
||||||
|
Publish(subject string, data []byte) error
|
||||||
|
|
||||||
|
// Request sends a request and waits for a reply with the given timeout.
|
||||||
|
Request(subject string, data []byte, timeout time.Duration) (*Message, error)
|
||||||
|
|
||||||
|
// Subscribe subscribes to a subject and returns a message channel.
|
||||||
|
Subscribe(subject string) (*Subscription, error)
|
||||||
|
|
||||||
|
// IsConnected returns whether the connection is active.
|
||||||
|
IsConnected() bool
|
||||||
|
|
||||||
|
// Close shuts down the connection.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a received NATS message.
|
||||||
|
type Message struct {
|
||||||
|
Subject string
|
||||||
|
Data []byte
|
||||||
|
Reply string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription wraps a NATS subscription.
|
||||||
|
type Subscription struct {
|
||||||
|
Subject string
|
||||||
|
MsgCh <-chan *Message
|
||||||
|
unsub func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes the subscription.
|
||||||
|
func (s *Subscription) Unsubscribe() error {
|
||||||
|
if s.unsub != nil {
|
||||||
|
return s.unsub()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InProcessConn is a simple in-process NATS connection for development and testing.
|
||||||
|
// It routes messages between publishers and subscribers within the same process.
|
||||||
|
type InProcessConn struct {
|
||||||
|
subs map[string][]chan *Message
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInProcess creates a new in-process NATS connection.
|
||||||
|
func NewInProcess() *InProcessConn {
|
||||||
|
return &InProcessConn{
|
||||||
|
subs: make(map[string][]chan *Message),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *InProcessConn) Publish(subject string, data []byte) error {
|
||||||
|
msg := &Message{Subject: subject, Data: data}
|
||||||
|
for _, ch := range c.subs[subject] {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default:
|
||||||
|
// Drop if subscriber is too slow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *InProcessConn) Request(subject string, data []byte, timeout time.Duration) (*Message, error) {
|
||||||
|
// In-process request: publish and wait for first reply on a temp reply subject
|
||||||
|
replySubj := subject + ".reply." + randomSuffix()
|
||||||
|
replyCh := make(chan *Message, 1)
|
||||||
|
|
||||||
|
c.subs[replySubj] = append(c.subs[replySubj], replyCh)
|
||||||
|
|
||||||
|
// Publish with reply subject
|
||||||
|
msg := &Message{Subject: subject, Data: data, Reply: replySubj}
|
||||||
|
for _, ch := range c.subs[subject] {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case reply := <-replyCh:
|
||||||
|
return reply, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *InProcessConn) Subscribe(subject string) (*Subscription, error) {
|
||||||
|
ch := make(chan *Message, 64)
|
||||||
|
c.subs[subject] = append(c.subs[subject], ch)
|
||||||
|
|
||||||
|
return &Subscription{
|
||||||
|
Subject: subject,
|
||||||
|
MsgCh: ch,
|
||||||
|
unsub: func() error {
|
||||||
|
// Remove this channel from subs (best effort)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *InProcessConn) IsConnected() bool { return true }
|
||||||
|
func (c *InProcessConn) Close() error {
|
||||||
|
close(c.done)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSuffix() string {
|
||||||
|
return time.Now().Format("150405.000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject constants — all Cyrene subjects follow the "cyrene.{service}.{action}" convention.
|
||||||
|
const (
|
||||||
|
SubjectPrefix = "cyrene"
|
||||||
|
|
||||||
|
SubjMemorySearch = "cyrene.memory.search"
|
||||||
|
SubjMemoryInsert = "cyrene.memory.insert"
|
||||||
|
SubjMemoryDelete = "cyrene.memory.delete"
|
||||||
|
SubjMemoryExtract = "cyrene.memory.extract"
|
||||||
|
|
||||||
|
SubjVoiceSTT = "cyrene.voice.stt"
|
||||||
|
SubjVoiceTTS = "cyrene.voice.tts"
|
||||||
|
|
||||||
|
SubjIoTDeviceList = "cyrene.iot.devices.list"
|
||||||
|
SubjIoTDeviceToggle = "cyrene.iot.device.toggle"
|
||||||
|
SubjIoTStateChanged = "cyrene.iot.state.changed"
|
||||||
|
|
||||||
|
SubjBridgeIncoming = "cyrene.bridge.incoming"
|
||||||
|
SubjBridgeOutgoing = "cyrene.bridge.outgoing"
|
||||||
|
|
||||||
|
SubjThinkerThoughtReady = "cyrene.thinker.thought.ready"
|
||||||
|
|
||||||
|
SubjRAGSearch = "cyrene.rag.search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure InProcessConn satisfies Connection
|
||||||
|
var _ Connection = (*InProcessConn)(nil)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package nats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInProcessConnSubscribeAndPublish(t *testing.T) {
|
||||||
|
conn := NewInProcess()
|
||||||
|
|
||||||
|
sub, err := conn.Subscribe("cyrene.test.echo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Subscribe error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.Publish("cyrene.test.echo", []byte("hello"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Publish error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-sub.MsgCh:
|
||||||
|
if msg.Subject != "cyrene.test.echo" {
|
||||||
|
t.Errorf("expected subject cyrene.test.echo, got %s", msg.Subject)
|
||||||
|
}
|
||||||
|
if string(msg.Data) != "hello" {
|
||||||
|
t.Errorf("expected data 'hello', got %s", string(msg.Data))
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timeout waiting for message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInProcessConnRequestReply(t *testing.T) {
|
||||||
|
conn := NewInProcess()
|
||||||
|
|
||||||
|
// Set up a responder
|
||||||
|
sub, err := conn.Subscribe("cyrene.test.add")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Subscribe error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responder goroutine
|
||||||
|
go func() {
|
||||||
|
msg := <-sub.MsgCh
|
||||||
|
if msg.Reply != "" {
|
||||||
|
conn.Publish(msg.Reply, []byte("42"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give responder a moment to start
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
reply, err := conn.Request("cyrene.test.add", []byte("40+2"), 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Request error: %v", err)
|
||||||
|
}
|
||||||
|
if string(reply.Data) != "42" {
|
||||||
|
t.Errorf("expected reply '42', got %s", string(reply.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInProcessConnMultipleSubscribers(t *testing.T) {
|
||||||
|
conn := NewInProcess()
|
||||||
|
|
||||||
|
sub1, _ := conn.Subscribe("cyrene.test.multi")
|
||||||
|
sub2, _ := conn.Subscribe("cyrene.test.multi")
|
||||||
|
|
||||||
|
conn.Publish("cyrene.test.multi", []byte("broadcast"))
|
||||||
|
|
||||||
|
// Both subscribers should receive
|
||||||
|
select {
|
||||||
|
case <-sub1.MsgCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("subscriber 1 did not receive message")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-sub2.MsgCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("subscriber 2 did not receive message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInProcessConnClose(t *testing.T) {
|
||||||
|
conn := NewInProcess()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Close error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !conn.IsConnected() {
|
||||||
|
// IsConnected returns true in our stub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubjectConstants(t *testing.T) {
|
||||||
|
// Verify subject naming follows the cyrene.{service}.{action} convention
|
||||||
|
subjects := []string{
|
||||||
|
SubjMemorySearch, SubjMemoryInsert, SubjMemoryDelete, SubjMemoryExtract,
|
||||||
|
SubjVoiceSTT, SubjVoiceTTS,
|
||||||
|
SubjIoTDeviceList, SubjIoTDeviceToggle, SubjIoTStateChanged,
|
||||||
|
SubjBridgeIncoming, SubjBridgeOutgoing,
|
||||||
|
SubjThinkerThoughtReady,
|
||||||
|
SubjRAGSearch,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subj := range subjects {
|
||||||
|
if len(subj) == 0 {
|
||||||
|
t.Error("subject should not be empty")
|
||||||
|
}
|
||||||
|
if subj[:len(SubjectPrefix)] != SubjectPrefix {
|
||||||
|
t.Errorf("subject %s should start with %s", subj, SubjectPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionUnsubscribe(t *testing.T) {
|
||||||
|
conn := NewInProcess()
|
||||||
|
sub, _ := conn.Subscribe("cyrene.test.temp")
|
||||||
|
|
||||||
|
err := sub.Unsubscribe()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unsubscribe error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionInterface(t *testing.T) {
|
||||||
|
// Verify InProcessConn satisfies Connection interface
|
||||||
|
var conn Connection = NewInProcess()
|
||||||
|
_ = conn
|
||||||
|
}
|
||||||
Reference in new issue
Block a user