Реализация rule-engine-service: RabbitMQ → правила из PostgreSQL → gRPC
Слушает telemetry.new_reading (ручной ack/nack, реквизишн только при транспортных ошибках gRPC), кэширует активные automation_rules в памяти с периодическим обновлением из PostgreSQL (JOIN с devices — резолвит внутренние ID в external_id, которым оперируют MQTT/Redis/gRPC). Условия правил (>,<,>=,<=,=,!=) оцениваются обобщённо, без привязки к конкретным типам устройств. При срабатывании вызывает device-control-service по gRPC и публикует automation.rule_triggered в RabbitMQ. Проверено сквозным тестом через docker compose на полном пайплайне: mosquitto_pub → ingest-service (ClickHouse + telemetry.new_reading) → rule-engine-service (совпадение правила) → device-control-service (gRPC → Redis desired_state + MQTT-команда) → automation.rule_triggered. Показание ниже порога проверено отдельно — правило корректно не срабатывает.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Package rulecache keeps active automation rules in memory, refreshed
|
||||
// periodically from PostgreSQL, so the hot path (one Redis/gRPC-free lookup
|
||||
// per incoming reading) never needs a database round-trip.
|
||||
package rulecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
|
||||
)
|
||||
|
||||
// Row is one automation_rules record already joined against devices, so
|
||||
// SourceExternalID/TargetExternalID are the external device IDs used by
|
||||
// MQTT/Redis/gRPC — not PostgreSQL's internal bigserial IDs.
|
||||
type Row struct {
|
||||
RuleID int64
|
||||
ZoneID int64
|
||||
SourceExternalID string
|
||||
TargetExternalID string
|
||||
ConditionSensorType string
|
||||
ConditionOperator string
|
||||
ConditionValue float64
|
||||
ActionType string
|
||||
ActionParams []byte // raw JSON, decoded during index build
|
||||
}
|
||||
|
||||
// Fetcher loads all currently active rules. The real implementation queries
|
||||
// PostgreSQL; tests supply a fake.
|
||||
type Fetcher interface {
|
||||
FetchActiveRules(ctx context.Context) ([]Row, error)
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
fetcher Fetcher
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
index map[string][]rules.Rule
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(fetcher Fetcher, interval time.Duration, logger *slog.Logger) *Cache {
|
||||
return &Cache{fetcher: fetcher, interval: interval, logger: logger}
|
||||
}
|
||||
|
||||
// Start performs a synchronous initial load (so the service never runs with
|
||||
// an empty cache) and then begins the periodic refresh loop.
|
||||
func (c *Cache) Start(ctx context.Context) error {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.stop = make(chan struct{})
|
||||
c.done = make(chan struct{})
|
||||
go c.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Stop() {
|
||||
close(c.stop)
|
||||
<-c.done
|
||||
}
|
||||
|
||||
func (c *Cache) loop() {
|
||||
defer close(c.done)
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.interval)
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
c.logger.Error("rule cache refresh failed", "error", err)
|
||||
}
|
||||
cancel()
|
||||
case <-c.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refresh(ctx context.Context) error {
|
||||
dbRows, err := c.fetcher.FetchActiveRules(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := buildIndex(dbRows, c.logger)
|
||||
c.mu.Lock()
|
||||
c.index = index
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MatchingRules returns the active rules that watch sensorType readings from
|
||||
// deviceID. Safe for concurrent use.
|
||||
func (c *Cache) MatchingRules(deviceID, sensorType string) []rules.Rule {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.index[cacheKey(deviceID, sensorType)]
|
||||
}
|
||||
|
||||
func cacheKey(deviceID, sensorType string) string {
|
||||
return deviceID + "\x00" + sensorType
|
||||
}
|
||||
|
||||
func buildIndex(dbRows []Row, logger *slog.Logger) map[string][]rules.Rule {
|
||||
index := make(map[string][]rules.Rule)
|
||||
for _, r := range dbRows {
|
||||
var params map[string]any
|
||||
if len(r.ActionParams) > 0 {
|
||||
if err := json.Unmarshal(r.ActionParams, ¶ms); err != nil {
|
||||
logger.Error("skipping rule with invalid action_params", "rule_id", r.RuleID, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rule := rules.Rule{
|
||||
ID: r.RuleID,
|
||||
ZoneID: r.ZoneID,
|
||||
SourceDeviceID: r.SourceExternalID,
|
||||
ConditionSensorType: r.ConditionSensorType,
|
||||
ConditionOperator: r.ConditionOperator,
|
||||
ConditionValue: r.ConditionValue,
|
||||
TargetDeviceID: r.TargetExternalID,
|
||||
ActionType: r.ActionType,
|
||||
ActionParams: params,
|
||||
}
|
||||
|
||||
key := cacheKey(r.SourceExternalID, r.ConditionSensorType)
|
||||
index[key] = append(index[key], rule)
|
||||
}
|
||||
return index
|
||||
}
|
||||
Reference in New Issue
Block a user