Реализация 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,96 @@
// Package engine orchestrates the core rule-engine loop: given one sensor
// reading, find the rules that watch it, evaluate their conditions, and
// dispatch the matching actions. It knows nothing about RabbitMQ, gRPC, or
// PostgreSQL directly — those are injected as narrow interfaces so this
// package is testable without any of them running.
package engine
import (
"context"
"log/slog"
"time"
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
)
// Reading is the generic sensor reading the engine reacts to — no
// growbox/device-type-specific fields, matching the platform's abstractions.
type Reading struct {
DeviceID string
ZoneID string
SensorType string
Value float64
RecordedAt time.Time
}
// RuleSource is the subset of rulecache.Cache the engine needs.
type RuleSource interface {
MatchingRules(deviceID, sensorType string) []rules.Rule
}
// Dispatcher is the subset of devicecontrolclient.Client the engine needs.
type Dispatcher interface {
Dispatch(ctx context.Context, deviceID, actionType string, params map[string]any) (devicecontrolclient.Result, error)
}
// TriggerPublisher is the subset of rabbitmq.Client the engine needs.
type TriggerPublisher interface {
PublishRuleTriggered(ctx context.Context, ruleID, zoneID int64, deviceID, actionType string, success bool, errMsg string) error
}
type Engine struct {
rules RuleSource
dispatcher Dispatcher
publisher TriggerPublisher
logger *slog.Logger
}
func New(ruleSource RuleSource, dispatcher Dispatcher, publisher TriggerPublisher, logger *slog.Logger) *Engine {
return &Engine{rules: ruleSource, dispatcher: dispatcher, publisher: publisher, logger: logger}
}
// HandleReading evaluates every rule watching (r.DeviceID, r.SensorType) and
// dispatches the ones whose condition matches. It returns a non-nil error
// only when a dispatch failed at the transport level (e.g.
// device-control-service unreachable) — that's the one case worth retrying
// the whole reading for. A rule the device itself rejected (bad device_id,
// unsupported action) is terminal and does not cause a retry.
func (e *Engine) HandleReading(ctx context.Context, r Reading) error {
matched := e.rules.MatchingRules(r.DeviceID, r.SensorType)
var firstTransportErr error
for _, rule := range matched {
matchesCondition, err := rules.Evaluate(rule.ConditionOperator, r.Value, rule.ConditionValue)
if err != nil {
e.logger.Error("skipping rule with invalid condition", "rule_id", rule.ID, "error", err)
continue
}
if !matchesCondition {
continue
}
result, dispatchErr := e.dispatcher.Dispatch(ctx, rule.TargetDeviceID, rule.ActionType, rule.ActionParams)
success := dispatchErr == nil && result.Success
errMsg := result.Error
switch {
case dispatchErr != nil:
errMsg = dispatchErr.Error()
e.logger.Error("dispatch action failed", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "error", dispatchErr)
if firstTransportErr == nil {
firstTransportErr = dispatchErr
}
case !result.Success:
e.logger.Warn("device rejected command", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "error", result.Error)
default:
e.logger.Info("rule triggered", "rule_id", rule.ID, "target_device_id", rule.TargetDeviceID, "action_type", rule.ActionType)
}
if pubErr := e.publisher.PublishRuleTriggered(ctx, rule.ID, rule.ZoneID, rule.TargetDeviceID, rule.ActionType, success, errMsg); pubErr != nil {
e.logger.Error("publish rule_triggered event failed", "rule_id", rule.ID, "error", pubErr)
}
}
return firstTransportErr
}
@@ -0,0 +1,156 @@
package engine
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
)
type fakeRuleSource struct {
rules []rules.Rule
}
func (f *fakeRuleSource) MatchingRules(_, _ string) []rules.Rule {
return f.rules
}
type dispatchCall struct {
deviceID string
actionType string
params map[string]any
}
type fakeDispatcher struct {
calls []dispatchCall
result devicecontrolclient.Result
err error
}
func (f *fakeDispatcher) Dispatch(_ context.Context, deviceID, actionType string, params map[string]any) (devicecontrolclient.Result, error) {
f.calls = append(f.calls, dispatchCall{deviceID: deviceID, actionType: actionType, params: params})
return f.result, f.err
}
type triggeredEvent struct {
ruleID int64
deviceID string
actionType string
success bool
errMsg string
}
type fakePublisher struct {
events []triggeredEvent
}
func (f *fakePublisher) PublishRuleTriggered(_ context.Context, ruleID, _ int64, deviceID, actionType string, success bool, errMsg string) error {
f.events = append(f.events, triggeredEvent{ruleID: ruleID, deviceID: deviceID, actionType: actionType, success: success, errMsg: errMsg})
return nil
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestHandleReading_NoMatchingRules(t *testing.T) {
dispatcher := &fakeDispatcher{}
publisher := &fakePublisher{}
e := New(&fakeRuleSource{}, dispatcher, publisher, discardLogger())
if err := e.HandleReading(context.Background(), Reading{DeviceID: "sensor-1", SensorType: "temperature", Value: 30}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dispatcher.calls) != 0 || len(publisher.events) != 0 {
t.Fatalf("expected no dispatch/publish, got %+v / %+v", dispatcher.calls, publisher.events)
}
}
func TestHandleReading_ConditionNotMet(t *testing.T) {
source := &fakeRuleSource{rules: []rules.Rule{
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
}}
dispatcher := &fakeDispatcher{}
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
if err := e.HandleReading(context.Background(), Reading{Value: 20}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dispatcher.calls) != 0 {
t.Fatalf("expected no dispatch when condition not met, got %+v", dispatcher.calls)
}
}
func TestHandleReading_ConditionMet_DispatchesAndPublishes(t *testing.T) {
source := &fakeRuleSource{rules: []rules.Rule{
{ID: 1, ZoneID: 7, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
}}
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
publisher := &fakePublisher{}
e := New(source, dispatcher, publisher, discardLogger())
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" || dispatcher.calls[0].actionType != "turn_on" {
t.Fatalf("got dispatch calls %+v, want one turn_on for fan-1", dispatcher.calls)
}
if len(publisher.events) != 1 || !publisher.events[0].success {
t.Fatalf("got publish events %+v, want one success event", publisher.events)
}
}
func TestHandleReading_BusinessRejection_NotRetried(t *testing.T) {
source := &fakeRuleSource{rules: []rules.Rule{
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
}}
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: false, Error: "device offline"}}
publisher := &fakePublisher{}
e := New(source, dispatcher, publisher, discardLogger())
err := e.HandleReading(context.Background(), Reading{Value: 30})
if err != nil {
t.Fatalf("business rejection should not be reported as a retryable error, got %v", err)
}
if len(publisher.events) != 1 || publisher.events[0].success || publisher.events[0].errMsg != "device offline" {
t.Fatalf("got publish events %+v, want one failed event with device offline", publisher.events)
}
}
func TestHandleReading_TransportError_IsRetryable(t *testing.T) {
source := &fakeRuleSource{rules: []rules.Rule{
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
}}
dispatcher := &fakeDispatcher{err: errors.New("connection refused")}
publisher := &fakePublisher{}
e := New(source, dispatcher, publisher, discardLogger())
err := e.HandleReading(context.Background(), Reading{Value: 30})
if err == nil {
t.Fatal("expected a retryable error when dispatch fails at the transport level")
}
if len(publisher.events) != 1 || publisher.events[0].success {
t.Fatalf("got publish events %+v, want one failed event recorded even on transport error", publisher.events)
}
}
func TestHandleReading_InvalidOperatorSkipsRuleButContinues(t *testing.T) {
source := &fakeRuleSource{rules: []rules.Rule{
{ID: 1, ConditionOperator: "~=", ConditionValue: 28, TargetDeviceID: "bad-rule"},
{ID: 2, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
}}
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" {
t.Fatalf("got dispatch calls %+v, want only the valid rule dispatched", dispatcher.calls)
}
}