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
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