Реализация 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,41 @@
// Package rules holds the generic automation-rule model and condition
// evaluation logic. Nothing here knows about growboxes, lights, or pumps —
// only sensor_type/operator/value, exactly as stored in automation_rules.
package rules
import "fmt"
// Rule mirrors one row of automation_rules, joined against devices so the
// device IDs are the external identifiers used by MQTT/Redis/gRPC (not
// PostgreSQL's internal bigserial IDs).
type Rule struct {
ID int64
ZoneID int64
SourceDeviceID string // condition_source_device_id, resolved to devices.external_id
ConditionSensorType string
ConditionOperator string
ConditionValue float64
TargetDeviceID string // target_device_id, resolved to devices.external_id
ActionType string
ActionParams map[string]any
}
// Evaluate reports whether value satisfies the rule's condition.
func Evaluate(operator string, value, threshold float64) (bool, error) {
switch operator {
case ">":
return value > threshold, nil
case "<":
return value < threshold, nil
case ">=":
return value >= threshold, nil
case "<=":
return value <= threshold, nil
case "=":
return value == threshold, nil
case "!=":
return value != threshold, nil
default:
return false, fmt.Errorf("unknown condition operator %q", operator)
}
}
@@ -0,0 +1,39 @@
package rules
import "testing"
func TestEvaluate(t *testing.T) {
cases := []struct {
operator string
value float64
threshold float64
want bool
}{
{">", 29, 28, true},
{">", 27, 28, false},
{"<", 27, 28, true},
{"<", 29, 28, false},
{">=", 28, 28, true},
{"<=", 28, 28, true},
{"=", 28, 28, true},
{"=", 28.1, 28, false},
{"!=", 28.1, 28, true},
{"!=", 28, 28, false},
}
for _, c := range cases {
got, err := Evaluate(c.operator, c.value, c.threshold)
if err != nil {
t.Fatalf("operator %q: unexpected error: %v", c.operator, err)
}
if got != c.want {
t.Errorf("Evaluate(%q, %v, %v) = %v, want %v", c.operator, c.value, c.threshold, got, c.want)
}
}
}
func TestEvaluate_UnknownOperator(t *testing.T) {
if _, err := Evaluate("~=", 1, 2); err == nil {
t.Fatal("expected error for unknown operator")
}
}