Files
Cyrene_v2/backend/internal/nats/client.go
T
Aska ca106f71ae 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
2026-08-09 20:45:19 +08:00

156 lines
4.0 KiB
Go

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