Реализация 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:
2026-07-26 21:18:59 +05:00
parent 3eb84aae6c
commit abc83faf05
20 changed files with 1334 additions and 10 deletions
@@ -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, &params); 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
}
@@ -0,0 +1,110 @@
package rulecache
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
)
type fakeFetcher struct {
mu sync.Mutex
rows []Row
calls chan struct{}
}
func (f *fakeFetcher) setRows(rows []Row) {
f.mu.Lock()
defer f.mu.Unlock()
f.rows = rows
}
func (f *fakeFetcher) FetchActiveRules(context.Context) ([]Row, error) {
f.mu.Lock()
rows := append([]Row(nil), f.rows...)
f.mu.Unlock()
if f.calls != nil {
f.calls <- struct{}{}
}
return rows, nil
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestCache_StartLoadsSynchronously(t *testing.T) {
fetcher := &fakeFetcher{rows: []Row{
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ConditionOperator: ">", ConditionValue: 28, TargetExternalID: "fan-1", ActionType: "turn_on"},
}}
c := New(fetcher, time.Hour, discardLogger())
if err := c.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer c.Stop()
got := c.MatchingRules("sensor-1", "temperature")
if len(got) != 1 || got[0].TargetDeviceID != "fan-1" {
t.Fatalf("got %+v, want one rule targeting fan-1", got)
}
// Different sensor_type on the same device must not match.
if got := c.MatchingRules("sensor-1", "humidity"); len(got) != 0 {
t.Fatalf("got %+v, want no rules for humidity", got)
}
}
func TestCache_SkipsRuleWithInvalidActionParams(t *testing.T) {
fetcher := &fakeFetcher{rows: []Row{
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ActionParams: []byte(`not json`)},
{RuleID: 2, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", ActionParams: []byte(`{"level":50}`)},
}}
c := New(fetcher, time.Hour, discardLogger())
if err := c.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer c.Stop()
got := c.MatchingRules("sensor-1", "temperature")
if len(got) != 1 || got[0].ID != 2 {
t.Fatalf("got %+v, want only rule 2 to survive", got)
}
}
func TestCache_RefreshesOnInterval(t *testing.T) {
calls := make(chan struct{}, 4)
fetcher := &fakeFetcher{calls: calls}
c := New(fetcher, 20*time.Millisecond, discardLogger())
if err := c.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer c.Stop()
<-calls // consume the initial synchronous load
fetcher.setRows([]Row{
{RuleID: 1, SourceExternalID: "sensor-1", ConditionSensorType: "temperature", TargetExternalID: "fan-1", ActionType: "turn_on"},
})
select {
case <-calls:
case <-time.After(time.Second):
t.Fatal("timed out waiting for periodic refresh")
}
got := c.MatchingRules("sensor-1", "temperature")
if len(got) != 1 {
t.Fatalf("got %+v after refresh, want the newly added rule", got)
}
}
func TestCache_StopWaitsForLoopExit(t *testing.T) {
c := New(&fakeFetcher{}, time.Hour, discardLogger())
if err := c.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
c.Stop() // must return without hanging
}
@@ -0,0 +1,50 @@
package rulecache
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// activeRulesQuery resolves condition_source_device_id/target_device_id (FKs
// into devices.id) to devices.external_id, since every other part of the
// platform (MQTT, Redis, gRPC) keys devices by their external ID, not
// PostgreSQL's internal bigserial ID.
const activeRulesQuery = `
SELECT
r.id AS rule_id,
r.zone_id AS zone_id,
src.external_id AS source_external_id,
tgt.external_id AS target_external_id,
r.condition_sensor_type AS condition_sensor_type,
r.condition_operator AS condition_operator,
r.condition_value AS condition_value,
r.action_type AS action_type,
r.action_params AS action_params
FROM automation_rules r
JOIN devices src ON src.id = r.condition_source_device_id
JOIN devices tgt ON tgt.id = r.target_device_id
WHERE r.is_active = true
`
type PostgresFetcher struct {
pool *pgxpool.Pool
}
func NewPostgresFetcher(pool *pgxpool.Pool) *PostgresFetcher {
return &PostgresFetcher{pool: pool}
}
func (f *PostgresFetcher) FetchActiveRules(ctx context.Context) ([]Row, error) {
dbRows, err := f.pool.Query(ctx, activeRulesQuery)
if err != nil {
return nil, fmt.Errorf("query active rules: %w", err)
}
rows, err := pgx.CollectRows(dbRows, pgx.RowToStructByName[Row])
if err != nil {
return nil, fmt.Errorf("scan active rules: %w", err)
}
return rows, nil
}