Реализация 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,70 @@
|
||||
// Package config loads rule-engine-service settings from environment
|
||||
// variables, matching the names used in the repo-root .env.example.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
PostgresDSN string
|
||||
|
||||
DeviceControlGRPCTarget string
|
||||
|
||||
RabbitMQURL string
|
||||
|
||||
RuleCacheRefreshInterval time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
PostgresDSN: fmt.Sprintf("postgres://%s:%s@%s:%s/%s",
|
||||
getEnv("POSTGRES_USER", "home_automation"),
|
||||
getEnv("POSTGRES_PASSWORD", ""),
|
||||
getEnv("POSTGRES_HOST", "localhost"),
|
||||
getEnv("POSTGRES_PORT", "5432"),
|
||||
getEnv("POSTGRES_DB", "home_automation"),
|
||||
),
|
||||
|
||||
DeviceControlGRPCTarget: fmt.Sprintf("%s:%s",
|
||||
getEnv("DEVICE_CONTROL_HOST", "localhost"),
|
||||
getEnv("DEVICE_CONTROL_GRPC_PORT", "50051"),
|
||||
),
|
||||
|
||||
RabbitMQURL: fmt.Sprintf("amqp://%s:%s@%s:%s/",
|
||||
getEnv("RABBITMQ_USER", "guest"),
|
||||
getEnv("RABBITMQ_PASSWORD", "guest"),
|
||||
getEnv("RABBITMQ_HOST", "localhost"),
|
||||
getEnv("RABBITMQ_PORT", "5672"),
|
||||
),
|
||||
}
|
||||
|
||||
interval, err := getEnvDuration("RULE_ENGINE_CACHE_REFRESH_INTERVAL", 15*time.Second)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RuleCacheRefreshInterval = interval
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package devicecontrolclient wraps the generated DeviceControl gRPC client,
|
||||
// translating an automation_rules row's generic action_type/action_params
|
||||
// into the specific TurnOn/TurnOff/SetLevel RPC.
|
||||
package devicecontrolclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
client devicecontrol.DeviceControlClient
|
||||
}
|
||||
|
||||
func Connect(target string) (*Client, error) {
|
||||
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to device-control-service at %s: %w", target, err)
|
||||
}
|
||||
return &Client{conn: conn, client: devicecontrol.NewDeviceControlClient(conn)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// newWithClient builds a Client around an already-constructed gRPC client,
|
||||
// bypassing Connect. Used by tests to inject a fake devicecontrol.DeviceControlClient.
|
||||
func newWithClient(client devicecontrol.DeviceControlClient) *Client {
|
||||
return &Client{client: client}
|
||||
}
|
||||
|
||||
// Result mirrors devicecontrol.CommandResult: Success/Error come from the
|
||||
// device-control-service's business logic, not a transport-level failure.
|
||||
type Result struct {
|
||||
Success bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// Dispatch issues actionType against deviceID, using params for actions that
|
||||
// need them (currently just set_level's "level"). The returned error is only
|
||||
// set for transport/protocol failures; a rejected command comes back as
|
||||
// Result{Success: false, Error: "..."}.
|
||||
func (c *Client) Dispatch(ctx context.Context, deviceID, actionType string, params map[string]any) (Result, error) {
|
||||
switch actionType {
|
||||
case "turn_on":
|
||||
res, err := c.client.TurnOn(ctx, &devicecontrol.TurnOnRequest{DeviceId: deviceID})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("turn_on rpc: %w", err)
|
||||
}
|
||||
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
|
||||
|
||||
case "turn_off":
|
||||
res, err := c.client.TurnOff(ctx, &devicecontrol.TurnOffRequest{DeviceId: deviceID})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("turn_off rpc: %w", err)
|
||||
}
|
||||
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
|
||||
|
||||
case "set_level":
|
||||
level, ok := numericParam(params, "level")
|
||||
if !ok {
|
||||
return Result{}, fmt.Errorf("set_level requires a numeric %q param, got %+v", "level", params)
|
||||
}
|
||||
res, err := c.client.SetLevel(ctx, &devicecontrol.SetLevelRequest{DeviceId: deviceID, Level: level})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("set_level rpc: %w", err)
|
||||
}
|
||||
return Result{Success: res.GetSuccess(), Error: res.GetError()}, nil
|
||||
|
||||
default:
|
||||
return Result{}, fmt.Errorf("unknown action_type %q", actionType)
|
||||
}
|
||||
}
|
||||
|
||||
func numericParam(params map[string]any, key string) (float64, bool) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case int:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package devicecontrolclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
)
|
||||
|
||||
type fakeGRPCClient struct {
|
||||
lastTurnOn *devicecontrol.TurnOnRequest
|
||||
lastTurnOff *devicecontrol.TurnOffRequest
|
||||
lastSetLevel *devicecontrol.SetLevelRequest
|
||||
result *devicecontrol.CommandResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGRPCClient) TurnOn(_ context.Context, in *devicecontrol.TurnOnRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
|
||||
f.lastTurnOn = in
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeGRPCClient) TurnOff(_ context.Context, in *devicecontrol.TurnOffRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
|
||||
f.lastTurnOff = in
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeGRPCClient) SetLevel(_ context.Context, in *devicecontrol.SetLevelRequest, _ ...grpc.CallOption) (*devicecontrol.CommandResult, error) {
|
||||
f.lastSetLevel = in
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestDispatch_TurnOn(t *testing.T) {
|
||||
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: true}}
|
||||
c := newWithClient(fake)
|
||||
|
||||
res, err := c.Dispatch(context.Background(), "fan-1", "turn_on", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("got %+v, want success", res)
|
||||
}
|
||||
if fake.lastTurnOn == nil || fake.lastTurnOn.DeviceId != "fan-1" {
|
||||
t.Fatalf("got TurnOn request %+v, want device_id=fan-1", fake.lastTurnOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_SetLevel(t *testing.T) {
|
||||
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: true}}
|
||||
c := newWithClient(fake)
|
||||
|
||||
res, err := c.Dispatch(context.Background(), "light-1", "set_level", map[string]any{"level": 42.5})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("got %+v, want success", res)
|
||||
}
|
||||
if fake.lastSetLevel == nil || fake.lastSetLevel.Level != 42.5 {
|
||||
t.Fatalf("got SetLevel request %+v, want level=42.5", fake.lastSetLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_SetLevel_MissingParam(t *testing.T) {
|
||||
c := newWithClient(&fakeGRPCClient{})
|
||||
|
||||
if _, err := c.Dispatch(context.Background(), "light-1", "set_level", nil); err == nil {
|
||||
t.Fatal("expected error for missing level param")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_UnknownActionType(t *testing.T) {
|
||||
c := newWithClient(&fakeGRPCClient{})
|
||||
|
||||
if _, err := c.Dispatch(context.Background(), "d1", "dance", nil); err == nil {
|
||||
t.Fatal("expected error for unknown action_type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_BusinessFailurePassesThrough(t *testing.T) {
|
||||
fake := &fakeGRPCClient{result: &devicecontrol.CommandResult{Success: false, Error: "device offline"}}
|
||||
c := newWithClient(fake)
|
||||
|
||||
res, err := c.Dispatch(context.Background(), "fan-1", "turn_off", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if res.Success || res.Error != "device offline" {
|
||||
t.Fatalf("got %+v, want business failure passed through", res)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package rabbitmq consumes ingest-service's "new reading" events and
|
||||
// publishes rule-trigger events for notification-service. One connection is
|
||||
// shared, with separate channels for consuming and publishing, since that's
|
||||
// the amqp best practice (a channel is not meant to be used concurrently for
|
||||
// unrelated flows).
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const (
|
||||
NewReadingQueue = "telemetry.new_reading" // declared by ingest-service; declared here too, idempotently
|
||||
RuleTriggeredQueue = "automation.rule_triggered"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
consumeCh *amqp.Channel
|
||||
publishCh *amqp.Channel
|
||||
}
|
||||
|
||||
func Connect(url string) (*Client, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
|
||||
consumeCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open consume channel: %w", err)
|
||||
}
|
||||
// Process one reading at a time: simple, demonstrable backpressure —
|
||||
// the next delivery isn't sent until the current one is acked/nacked.
|
||||
if err := consumeCh.Qos(1, 0, false); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
if _, err := consumeCh.QueueDeclare(NewReadingQueue, true, false, false, false, nil); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", NewReadingQueue, err)
|
||||
}
|
||||
|
||||
publishCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("open publish channel: %w", err)
|
||||
}
|
||||
if _, err := publishCh.QueueDeclare(RuleTriggeredQueue, true, false, false, false, nil); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("declare queue %s: %w", RuleTriggeredQueue, err)
|
||||
}
|
||||
|
||||
return &Client{conn: conn, consumeCh: consumeCh, publishCh: publishCh}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReadingEvent mirrors ingest-service's newReadingEvent wire format.
|
||||
type ReadingEvent struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
SensorType string `json:"sensor_type"`
|
||||
Value float64 `json:"value"`
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// HandleResult tells ConsumeReadings how to settle a delivery.
|
||||
type HandleResult int
|
||||
|
||||
const (
|
||||
Ack HandleResult = iota // processed successfully
|
||||
NackRequeue // transient failure (e.g. gRPC unreachable) — try again later
|
||||
NackDiscard // permanent failure (e.g. bad data) — retrying won't help
|
||||
)
|
||||
|
||||
// Handler processes one reading event and decides how it should be settled.
|
||||
type Handler func(ctx context.Context, event ReadingEvent) HandleResult
|
||||
|
||||
// ConsumeReadings blocks, delivering messages to handler, until ctx is
|
||||
// cancelled or the delivery channel closes (e.g. connection lost).
|
||||
func (c *Client) ConsumeReadings(ctx context.Context, handler Handler) error {
|
||||
deliveries, err := c.consumeCh.ConsumeWithContext(ctx, NewReadingQueue, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume %s: %w", NewReadingQueue, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel for %s closed", NewReadingQueue)
|
||||
}
|
||||
|
||||
var event ReadingEvent
|
||||
if err := json.Unmarshal(d.Body, &event); err != nil {
|
||||
// Poison message: no amount of retrying fixes malformed JSON.
|
||||
_ = d.Nack(false, false)
|
||||
continue
|
||||
}
|
||||
|
||||
switch handler(ctx, event) {
|
||||
case Ack:
|
||||
_ = d.Ack(false)
|
||||
case NackRequeue:
|
||||
_ = d.Nack(false, true)
|
||||
case NackDiscard:
|
||||
_ = d.Nack(false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type ruleTriggeredEvent struct {
|
||||
RuleID int64 `json:"rule_id"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActionType string `json:"action_type"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PublishRuleTriggered emits one event per rule match+dispatch attempt, so
|
||||
// notification-service can (eventually) alert on it.
|
||||
func (c *Client) PublishRuleTriggered(ctx context.Context, ruleID, zoneID int64, deviceID, actionType string, success bool, errMsg string) error {
|
||||
body, err := json.Marshal(ruleTriggeredEvent{
|
||||
RuleID: ruleID,
|
||||
ZoneID: zoneID,
|
||||
DeviceID: deviceID,
|
||||
ActionType: actionType,
|
||||
Success: success,
|
||||
Error: errMsg,
|
||||
At: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
|
||||
err = c.publishCh.PublishWithContext(ctx, "", RuleTriggeredQueue, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish rule triggered event for rule %d: %w", ruleID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user