Compare commits

..

No commits in common. "feat/bootstrap-data-objects-from-pg-to-redis" and "develop" have entirely different histories.

45 changed files with 590 additions and 3943 deletions

4
.gitignore vendored
View File

@ -30,7 +30,6 @@ go.work
/configs/**/*.pem /configs/**/*.pem
# ai config # ai config
.agents/
.cursor/ .cursor/
.claude/ .claude/
.codewhale/ .codewhale/
@ -42,6 +41,3 @@ go.work
ai-debug.log ai-debug.log
*.patch *.patch
*.diff *.diff
docs/agents/
AGENTS.md
skills-lock.json

View File

@ -1,255 +0,0 @@
// Package manualsync synchronizes measurement manual-mode changes with the
// protocol service responsible for the measurement's data source
package manualsync
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"modelRT/config"
"modelRT/constants"
"modelRT/orm"
)
const maxErrorResponseBody = 4 << 10
// SyntheticData is one manually supplied measurement value
type SyntheticData struct {
Time int64 `json:"time"`
Value float64 `json:"value"`
}
// Target identifies a measurement in a downstream protocol service
type Target struct {
Type int `json:"type"`
Station string `json:"station"`
MainPos string `json:"main_pos"`
SubPos string `json:"sub_pos"`
Option string `json:"option"`
}
// Request is the payload accepted by POST /api/manual
type Request struct {
Mode int16 `json:"mode"`
Data []SyntheticData `json:"data,omitempty"`
Target Target `json:"target"`
}
// Syncer synchronizes a measurement mode or manual-value change
type Syncer interface {
Sync(context.Context, orm.JSONMap, int16, *SyntheticData) error
}
// Client calls the protocol-specific manual synchronization endpoint
type Client struct {
httpClient *http.Client
protocolCL3611URL string
protocol104URL string
}
// NewClient validates the configuration and constructs a reusable client
func NewClient(cfg config.ManualSyncConfig) (*Client, error) {
if cfg.Timeout <= 0 {
return nil, fmt.Errorf("manual sync timeout must be greater than zero")
}
protocolCL3611URL, err := endpointURL(cfg.ProtocolCL3611URL, cfg.APIPath)
if err != nil {
return nil, fmt.Errorf("invalid protocol CL3611 URL: %w", err)
}
protocol104URL, err := endpointURL(cfg.Protocol104URL, cfg.APIPath)
if err != nil {
return nil, fmt.Errorf("invalid protocol 104 URL: %w", err)
}
return &Client{
httpClient: &http.Client{Timeout: cfg.Timeout},
protocolCL3611URL: protocolCL3611URL,
protocol104URL: protocol104URL,
}, nil
}
// Sync posts one mode transition or manual-value update. Data is omitted for
// mode transitions and included only when sample is non-nil in manual mode
func (c *Client) Sync(ctx context.Context, dataSource orm.JSONMap, mode int16, data *SyntheticData) error {
if c == nil || c.httpClient == nil {
return fmt.Errorf("manual sync client is not initialized")
}
if mode != constants.MeasurementModeManual && mode != constants.MeasurementModeAutomatic {
return fmt.Errorf("manual sync mode must be 0 or 1, got %d", mode)
}
if mode == constants.MeasurementModeAutomatic && data != nil {
return fmt.Errorf("automatic mode manual sync request cannot contain data")
}
endpoint, target, err := c.resolveTarget(dataSource)
if err != nil {
return err
}
requestPayload := Request{Mode: mode, Target: target}
if data != nil {
requestPayload.Data = []SyntheticData{*data}
}
body, err := json.Marshal(requestPayload)
if err != nil {
return fmt.Errorf("encode manual sync request: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create manual sync request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("call manual sync endpoint: %w", err)
}
defer response.Body.Close()
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
_, _ = io.Copy(io.Discard, response.Body)
return nil
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxErrorResponseBody))
if readErr != nil {
return fmt.Errorf("manual sync endpoint returned %s and response body could not be read: %w", response.Status, readErr)
}
message := strings.TrimSpace(string(responseBody))
if message == "" {
return fmt.Errorf("manual sync endpoint returned %s", response.Status)
}
return fmt.Errorf("manual sync endpoint returned %s: %s", response.Status, message)
}
type rawDataSource struct {
Type int `json:"type"`
IOAddress rawIOAddress `json:"io_address"`
}
type rawIOAddress struct {
DType int `json:"dtype"`
Station string `json:"station"`
Device string `json:"device"`
Channel string `json:"channel"`
Option string `json:"option"`
Packet any `json:"packet"`
Offset any `json:"offset"`
}
func (c *Client) resolveTarget(dataSource orm.JSONMap) (string, Target, error) {
if dataSource == nil {
return "", Target{}, fmt.Errorf("measurement data_source is null")
}
encoded, err := json.Marshal(dataSource)
if err != nil {
return "", Target{}, fmt.Errorf("encode measurement data_source: %w", err)
}
var source rawDataSource
if err := json.Unmarshal(encoded, &source); err != nil {
return "", Target{}, fmt.Errorf("decode measurement data_source: %w", err)
}
station := strings.TrimSpace(source.IOAddress.Station)
if station == "" {
return "", Target{}, fmt.Errorf("measurement data_source io_address.station is required")
}
switch source.Type {
case 1:
device := strings.TrimSpace(source.IOAddress.Device)
channel := strings.TrimSpace(source.IOAddress.Channel)
if device == "" {
return "", Target{}, fmt.Errorf("CL3611 data_source io_address.device is required")
}
if channel == "" {
return "", Target{}, fmt.Errorf("CL3611 data_source io_address.channel is required")
}
target := Target{
Station: station,
MainPos: device,
SubPos: channel,
}
switch source.IOAddress.DType {
case 1:
target.Type = 1
target.Option = strings.TrimSpace(source.IOAddress.Option)
case 2:
target.Type = 2
default:
return "", Target{}, fmt.Errorf("CL3611 data_source dtype must be 1 or 2, got %d", source.IOAddress.DType)
}
return c.protocolCL3611URL, target, nil
case 2:
packet, err := integerString(source.IOAddress.Packet)
if err != nil {
return "", Target{}, fmt.Errorf("104 data_source io_address.packet: %w", err)
}
offset, err := integerString(source.IOAddress.Offset)
if err != nil {
return "", Target{}, fmt.Errorf("104 data_source io_address.offset: %w", err)
}
return c.protocol104URL, Target{
Type: 3,
Station: station,
MainPos: packet,
SubPos: offset,
Option: "",
}, nil
default:
return "", Target{}, fmt.Errorf("unsupported measurement data_source type %d", source.Type)
}
}
func endpointURL(baseURL, apiPath string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("base URL is required")
}
apiPath = strings.TrimSpace(apiPath)
if apiPath == "" {
return "", fmt.Errorf("API path is required")
}
endpoint := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(apiPath, "/")
parsed, err := url.Parse(endpoint)
if err != nil {
return "", err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("URL scheme must be http or https")
}
if parsed.Host == "" {
return "", fmt.Errorf("URL host is required")
}
return parsed.String(), nil
}
func integerString(value any) (string, error) {
switch typed := value.(type) {
case nil:
return "", fmt.Errorf("is required")
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed {
return "", fmt.Errorf("must be an integer")
}
return strconv.FormatInt(int64(typed), 10), nil
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return "", fmt.Errorf("is required")
}
integer, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return "", fmt.Errorf("must be an integer: %w", err)
}
return strconv.FormatInt(integer, 10), nil
default:
return "", fmt.Errorf("has unsupported type %T", value)
}
}

View File

@ -1,240 +0,0 @@
package manualsync
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"modelRT/config"
"modelRT/constants"
"modelRT/orm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type capturedRequest struct {
Path string
Method string
ContentType string
Payload Request
}
func TestClientSyncRoutesAndMapsDataSources(t *testing.T) {
cl3611Requests := make(chan capturedRequest, 2)
protocol104Requests := make(chan capturedRequest, 1)
client, err := NewClient(config.ManualSyncConfig{
ProtocolCL3611URL: "http://cl3611.test",
Protocol104URL: "http://protocol104.test",
APIPath: "/api/manual",
Timeout: time.Second,
})
require.NoError(t, err)
client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
var payload Request
require.NoError(t, json.NewDecoder(request.Body).Decode(&payload))
captured := capturedRequest{
Path: request.URL.Path,
Method: request.Method,
ContentType: request.Header.Get("Content-Type"),
Payload: payload,
}
if request.URL.Host == "cl3611.test" {
cl3611Requests <- captured
} else {
protocol104Requests <- captured
}
return httpResponse(http.StatusNoContent, ""), nil
})
tests := []struct {
name string
dataSource orm.JSONMap
requests <-chan capturedRequest
wantTarget Target
}{
{
name: "CL3611 phasor",
dataSource: orm.JSONMap{
"type": 1,
"io_address": map[string]any{
"dtype": 1, "station": "001", "device": "ssu001",
"channel": "TM1", "option": "RMS",
},
},
requests: cl3611Requests,
wantTarget: Target{
Type: 1, Station: "001", MainPos: "ssu001", SubPos: "TM1", Option: "RMS",
},
},
{
name: "CL3611 sample",
dataSource: orm.JSONMap{
"type": 1,
"io_address": map[string]any{
"dtype": 2, "station": "002", "device": "ssu002",
"channel": "TS01", "option": "ignored",
},
},
requests: cl3611Requests,
wantTarget: Target{
Type: 2, Station: "002", MainPos: "ssu002", SubPos: "TS01", Option: "",
},
},
{
name: "104",
dataSource: orm.JSONMap{
"type": 2,
"io_address": map[string]any{
"station": "station000", "packet": 10, "offset": 35,
},
},
requests: protocol104Requests,
wantTarget: Target{
Type: 3, Station: "station000", MainPos: "10", SubPos: "35", Option: "",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := client.Sync(context.Background(), test.dataSource, constants.MeasurementModeAutomatic, nil)
require.NoError(t, err)
captured := <-test.requests
assert.Equal(t, http.MethodPost, captured.Method)
assert.Equal(t, "/api/manual", captured.Path)
assert.Equal(t, "application/json", captured.ContentType)
assert.Equal(t, constants.MeasurementModeAutomatic, captured.Payload.Mode)
assert.Nil(t, captured.Payload.Data)
assert.Equal(t, test.wantTarget, captured.Payload.Target)
})
}
}
func TestClientSyncIncludesManualValueData(t *testing.T) {
requests := make(chan capturedRequest, 1)
client, err := NewClient(config.ManualSyncConfig{
ProtocolCL3611URL: "http://cl3611.test",
Protocol104URL: "http://protocol104.test",
APIPath: "api/manual",
Timeout: time.Second,
})
require.NoError(t, err)
client.httpClient.Transport = captureTransport(t, requests)
sample := SyntheticData{Time: 1736305467506000000, Value: 1.25}
err = client.Sync(context.Background(), orm.JSONMap{
"type": 2,
"io_address": map[string]any{
"station": "station000", "packet": 1, "offset": 2,
},
}, constants.MeasurementModeManual, &sample)
require.NoError(t, err)
captured := <-requests
assert.Equal(t, constants.MeasurementModeManual, captured.Payload.Mode)
assert.Equal(t, []SyntheticData{sample}, captured.Payload.Data)
}
func TestClientSyncRejectsInvalidDataSources(t *testing.T) {
client, err := NewClient(config.ManualSyncConfig{
ProtocolCL3611URL: "http://cl3611.test",
Protocol104URL: "http://protocol104.test",
APIPath: "/api/manual",
Timeout: time.Second,
})
require.NoError(t, err)
client.httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("invalid data source must not call endpoint")
return nil, nil
})
tests := []struct {
name string
dataSource orm.JSONMap
wantError string
}{
{name: "unsupported type", dataSource: orm.JSONMap{"type": 3, "io_address": map[string]any{"station": "s"}}, wantError: "unsupported"},
{name: "invalid dtype", dataSource: orm.JSONMap{"type": 1, "io_address": map[string]any{"dtype": 3, "station": "s", "device": "d", "channel": "c"}}, wantError: "dtype"},
{name: "missing station", dataSource: orm.JSONMap{"type": 2, "io_address": map[string]any{"packet": 1, "offset": 2}}, wantError: "station"},
{name: "fractional packet", dataSource: orm.JSONMap{"type": 2, "io_address": map[string]any{"station": "s", "packet": 1.5, "offset": 2}}, wantError: "packet"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := client.Sync(context.Background(), test.dataSource, constants.MeasurementModeManual, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), test.wantError)
})
}
}
func TestClientSyncReturnsNonSuccessResponse(t *testing.T) {
client, err := NewClient(config.ManualSyncConfig{
ProtocolCL3611URL: "http://cl3611.test",
Protocol104URL: "http://protocol104.test",
APIPath: "/api/manual",
Timeout: time.Second,
})
require.NoError(t, err)
client.httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return httpResponse(http.StatusServiceUnavailable, "downstream unavailable"), nil
})
err = client.Sync(context.Background(), orm.JSONMap{
"type": 2,
"io_address": map[string]any{
"station": "s", "packet": 1, "offset": 2,
},
}, constants.MeasurementModeManual, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "Service Unavailable")
assert.Contains(t, err.Error(), "downstream unavailable")
}
func TestNewClientValidatesConfiguration(t *testing.T) {
_, err := NewClient(config.ManualSyncConfig{})
require.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
_, err = NewClient(config.ManualSyncConfig{
ProtocolCL3611URL: "127.0.0.1:9001",
Protocol104URL: "http://127.0.0.1:9002",
APIPath: "/api/manual",
Timeout: time.Second,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid protocol CL3611 URL")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return roundTrip(request)
}
func captureTransport(t *testing.T, requests chan<- capturedRequest) http.RoundTripper {
t.Helper()
return roundTripFunc(func(request *http.Request) (*http.Response, error) {
var payload Request
require.NoError(t, json.NewDecoder(request.Body).Decode(&payload))
requests <- capturedRequest{
Path: request.URL.Path,
Method: request.Method,
ContentType: request.Header.Get("Content-Type"),
Payload: payload,
}
return httpResponse(http.StatusNoContent, ""), nil
})
}
func httpResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

View File

@ -1,33 +0,0 @@
package manualsync
import (
"context"
"fmt"
"sync"
"modelRT/orm"
)
var (
defaultSyncerMu sync.RWMutex
defaultSyncer Syncer
)
// SetDefaultSyncer sets the process-wide manual measurement syncer.
// Passing nil clears the current default.
func SetDefaultSyncer(syncer Syncer) {
defaultSyncerMu.Lock()
defer defaultSyncerMu.Unlock()
defaultSyncer = syncer
}
// Sync uses the process-wide manual measurement syncer.
func Sync(ctx context.Context, dataSource orm.JSONMap, mode int16, sample *SyntheticData) error {
defaultSyncerMu.RLock()
syncer := defaultSyncer
defaultSyncerMu.RUnlock()
if syncer == nil {
return fmt.Errorf("manual measurement sync client is not initialized")
}
return syncer.Sync(ctx, dataSource, mode, sample)
}

View File

@ -1,46 +0,0 @@
package manualsync
import (
"context"
"testing"
"modelRT/constants"
"modelRT/orm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type syncerFunc func(context.Context, orm.JSONMap, int16, *SyntheticData) error
func (syncer syncerFunc) Sync(ctx context.Context, dataSource orm.JSONMap, mode int16, sample *SyntheticData) error {
return syncer(ctx, dataSource, mode, sample)
}
func TestSyncRequiresDefaultSyncer(t *testing.T) {
SetDefaultSyncer(nil)
t.Cleanup(func() { SetDefaultSyncer(nil) })
err := Sync(context.Background(), nil, constants.MeasurementModeManual, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "not initialized")
}
func TestSyncUsesDefaultSyncer(t *testing.T) {
SetDefaultSyncer(nil)
t.Cleanup(func() { SetDefaultSyncer(nil) })
dataSource := orm.JSONMap{"type": 2}
sample := &SyntheticData{Time: 123, Value: 4.5}
called := false
SetDefaultSyncer(syncerFunc(func(_ context.Context, gotDataSource orm.JSONMap, gotMode int16, gotSample *SyntheticData) error {
called = true
assert.Equal(t, dataSource, gotDataSource)
assert.Equal(t, constants.MeasurementModeManual, gotMode)
assert.Same(t, sample, gotSample)
return nil
}))
require.NoError(t, Sync(context.Background(), dataSource, constants.MeasurementModeManual, sample))
assert.True(t, called)
}

View File

@ -1,22 +0,0 @@
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
err := run(ctx, logger, net.Listen)
stop()
if err != nil {
logger.Error("manual sync mock stopped", "error", err)
os.Exit(1)
}
}

View File

@ -1,168 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"modelRT/client/manualsync"
)
type listenFunc func(network, address string) (net.Listener, error)
const (
cl3611Address = ":9001"
protocol104Address = ":9002"
manualAPIPath = "/api/manual"
healthPath = "/healthz"
maxRequestBody = 1 << 20
shutdownTimeout = 5 * time.Second
)
func newProtocolServer(protocol, address string, logger *slog.Logger) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("POST "+manualAPIPath, manualHandler(protocol, strings.TrimPrefix(address, ":"), logger))
mux.HandleFunc("GET "+healthPath, healthHandler)
return &http.Server{
Addr: address,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
func run(ctx context.Context, logger *slog.Logger, listen listenFunc) error {
cl3611Listener, err := listen("tcp", cl3611Address)
if err != nil {
return fmt.Errorf("listen on %s: %w", cl3611Address, err)
}
protocol104Listener, err := listen("tcp", protocol104Address)
if err != nil {
_ = cl3611Listener.Close()
return fmt.Errorf("listen on %s: %w", protocol104Address, err)
}
servers := []struct {
protocol string
server *http.Server
listener net.Listener
}{
{protocol: "cl3611", server: newProtocolServer("cl3611", cl3611Address, logger), listener: cl3611Listener},
{protocol: "104", server: newProtocolServer("104", protocol104Address, logger), listener: protocol104Listener},
}
serveErrors := make(chan error, len(servers))
var serversWaitGroup sync.WaitGroup
for _, configuredServer := range servers {
serversWaitGroup.Add(1)
go func() {
defer serversWaitGroup.Done()
logger.Info("manual sync mock server started",
"protocol", configuredServer.protocol,
"listen_addr", configuredServer.listener.Addr().String(),
)
if err := configuredServer.server.Serve(configuredServer.listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErrors <- fmt.Errorf("%s server failed: %w", configuredServer.protocol, err)
}
}()
}
var runError error
select {
case <-ctx.Done():
case runError = <-serveErrors:
}
shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancelShutdown()
for _, configuredServer := range servers {
if err := configuredServer.server.Shutdown(shutdownContext); err != nil {
runError = errors.Join(runError, fmt.Errorf("shut down %s server: %w", configuredServer.protocol, err))
_ = configuredServer.server.Close()
}
}
serversWaitGroup.Wait()
return runError
}
func healthHandler(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "text/plain; charset=utf-8")
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte("ok\n"))
}
func manualHandler(protocol, listenPort string, logger *slog.Logger) http.HandlerFunc {
return func(response http.ResponseWriter, request *http.Request) {
request.Body = http.MaxBytesReader(response, request.Body, maxRequestBody)
var payload manualsync.Request
decoder := json.NewDecoder(request.Body)
if err := decoder.Decode(&payload); err != nil {
logger.LogAttrs(request.Context(), slog.LevelWarn, "invalid manual sync request",
slog.String("protocol", protocol),
slog.String("listen_port", listenPort),
slog.String("remote_addr", request.RemoteAddr),
slog.Any("error", err),
)
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
http.Error(response, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(response, "invalid JSON request body", http.StatusBadRequest)
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
err = errors.New("request body contains multiple JSON documents")
}
logger.LogAttrs(request.Context(), slog.LevelWarn, "invalid manual sync request",
slog.String("protocol", protocol),
slog.String("listen_port", listenPort),
slog.String("remote_addr", request.RemoteAddr),
slog.Any("error", err),
)
http.Error(response, "invalid JSON request body", http.StatusBadRequest)
return
}
logger.LogAttrs(request.Context(), slog.LevelInfo, "manual sync request received",
slog.String("protocol", protocol),
slog.String("listen_port", listenPort),
slog.String("remote_addr", request.RemoteAddr),
slog.Int("mode", int(payload.Mode)),
slog.Group("target",
slog.Int("type", payload.Target.Type),
slog.String("station", payload.Target.Station),
slog.String("main_pos", payload.Target.MainPos),
slog.String("sub_pos", payload.Target.SubPos),
slog.String("option", payload.Target.Option),
),
slog.Attr{Key: "data", Value: samplesLogValue(payload.Data)},
)
response.WriteHeader(http.StatusNoContent)
}
}
func samplesLogValue(samples []manualsync.SyntheticData) slog.Value {
attributes := make([]slog.Attr, 0, len(samples))
for index, sample := range samples {
attributes = append(attributes, slog.Group(strconv.Itoa(index),
slog.Int64("time", sample.Time),
slog.Float64("value", sample.Value),
))
}
return slog.GroupValue(attributes...)
}

View File

@ -1,234 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHealthEndpointReportsServerIsReady(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
request := httptest.NewRequest(http.MethodGet, healthPath, nil)
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
result := response.Result()
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", result.StatusCode, http.StatusOK)
}
if contentType := result.Header.Get("Content-Type"); contentType != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q, want %q", contentType, "text/plain; charset=utf-8")
}
if string(body) != "ok\n" {
t.Errorf("body = %q, want %q", body, "ok\\n")
}
}
func TestManualEndpointLogsStructuredRequest(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, nil))
server := newProtocolServer("cl3611", cl3611Address, logger)
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{
"mode": 1,
"data": [{"time": 1736305467506000000, "value": 1.25}],
"target": {
"type": 1,
"station": "001",
"main_pos": "ssu001",
"sub_pos": "TM1",
"option": "RMS"
}
}`))
request.RemoteAddr = "127.0.0.1:52130"
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
for _, fragment := range []string{
"protocol=cl3611",
"listen_port=9001",
"remote_addr=127.0.0.1:52130",
"mode=1",
"target.type=1",
"target.station=001",
"target.main_pos=ssu001",
"target.sub_pos=TM1",
"target.option=RMS",
"time=1736305467506000000",
"value=1.25",
} {
if !strings.Contains(logs.String(), fragment) {
t.Errorf("log %q does not contain %q", logs.String(), fragment)
}
}
}
func TestManualEndpointRejectsInvalidJSONAndLogsTheError(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, nil))
server := newProtocolServer("104", protocol104Address, logger)
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{"mode":`))
request.RemoteAddr = "127.0.0.1:52131"
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
for _, fragment := range []string{
"level=WARN",
"msg=\"invalid manual sync request\"",
"protocol=104",
"listen_port=9002",
"remote_addr=127.0.0.1:52131",
"error=",
} {
if !strings.Contains(logs.String(), fragment) {
t.Errorf("log %q does not contain %q", logs.String(), fragment)
}
}
}
func TestManualEndpointRejectsOversizedRequest(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
body := `{"extra":"` + strings.Repeat("a", maxRequestBody) + `"}`
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(body))
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge)
}
}
func TestManualEndpointRejectsMultipleJSONDocuments(t *testing.T) {
server := newProtocolServer("104", protocol104Address, slog.Default())
request := httptest.NewRequest(http.MethodPost, manualAPIPath, strings.NewReader(`{"mode":1}{"mode":0}`))
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestRunClosesFirstListenerWhenSecondListenerFails(t *testing.T) {
rawListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on temporary port: %v", err)
}
firstListener := rawListener.(*net.TCPListener)
listenCalls := 0
listen := func(_, _ string) (net.Listener, error) {
listenCalls++
if listenCalls == 1 {
return firstListener, nil
}
return nil, errors.New("port is already in use")
}
err = run(context.Background(), slog.Default(), listen)
if err == nil || !strings.Contains(err.Error(), protocol104Address) {
t.Fatalf("run error = %v, want error containing %q", err, protocol104Address)
}
if err := firstListener.SetDeadline(time.Now()); err == nil {
t.Fatal("first listener is still open")
}
}
func TestRunServesBothProtocolsAndStopsOnCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
addresses := make(chan string, 2)
listen := func(_, _ string) (net.Listener, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err == nil {
addresses <- listener.Addr().String()
}
return listener, err
}
runErrors := make(chan error, 1)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
go func() {
runErrors <- run(ctx, logger, listen)
}()
cl3611Addr := <-addresses
protocol104Addr := <-addresses
waitForHealthyEndpoint(t, cl3611Addr)
waitForHealthyEndpoint(t, protocol104Addr)
cancel()
select {
case err := <-runErrors:
if err != nil {
t.Fatalf("run returned an error during graceful shutdown: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("servers did not stop after context cancellation")
}
}
func TestProtocolServerEnforcesMethodAndPathRouting(t *testing.T) {
server := newProtocolServer("cl3611", cl3611Address, slog.Default())
tests := []struct {
name string
method string
path string
wantStatus int
}{
{name: "manual endpoint rejects GET", method: http.MethodGet, path: manualAPIPath, wantStatus: http.StatusMethodNotAllowed},
{name: "unknown path is not found", method: http.MethodGet, path: "/unknown", wantStatus: http.StatusNotFound},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, test.path, nil)
response := httptest.NewRecorder()
server.Handler.ServeHTTP(response, request)
if response.Code != test.wantStatus {
t.Fatalf("status = %d, want %d", response.Code, test.wantStatus)
}
})
}
}
func waitForHealthyEndpoint(t *testing.T, address string) {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
for {
response, err := client.Get("http://" + address + healthPath)
if err == nil {
_ = response.Body.Close()
if response.StatusCode == http.StatusOK {
return
}
}
if time.Now().After(deadline) {
t.Fatalf("health endpoint at %s did not become ready", address)
}
time.Sleep(10 * time.Millisecond)
}
}

View File

@ -91,18 +91,12 @@ type AntsConfig struct {
RTDReceiveConcurrentQuantity int `mapstructure:"rtd_receive_concurrent_quantity"` // polling real time data concurrent quantity RTDReceiveConcurrentQuantity int `mapstructure:"rtd_receive_concurrent_quantity"` // polling real time data concurrent quantity
} }
// ManualSyncConfig defines protocol endpoints used to synchronize manual // DataRTConfig define config struct of data runtime server api config
// measurement mode and value changes.
type ManualSyncConfig struct {
ProtocolCL3611URL string `mapstructure:"protocol_cl3611_url"`
Protocol104URL string `mapstructure:"protocol_104_url"`
APIPath string `mapstructure:"api_path"`
Timeout time.Duration `mapstructure:"timeout"`
}
// DataRTConfig defines APIs provided by dataRT.
type DataRTConfig struct { type DataRTConfig struct {
ManualSync ManualSyncConfig `mapstructure:"manual_sync"` Host string `mapstructure:"host"`
Port int64 `mapstructure:"port"`
PollingAPI string `mapstructure:"polling_api"`
Method string `mapstructure:"polling_api_method"`
} }
// OtelConfig define config struct of OpenTelemetry tracing // OtelConfig define config struct of OpenTelemetry tracing
@ -130,7 +124,7 @@ type ModelRTConfig struct {
KafkaConfig `mapstructure:"kafka"` KafkaConfig `mapstructure:"kafka"`
LoggerConfig `mapstructure:"logger"` LoggerConfig `mapstructure:"logger"`
AntsConfig `mapstructure:"ants"` AntsConfig `mapstructure:"ants"`
DataRTConfig DataRTConfig `mapstructure:"dataRT"` DataRTConfig `mapstructure:"dataRT"`
LockerRedisConfig RedisConfig `mapstructure:"locker_redis"` LockerRedisConfig RedisConfig `mapstructure:"locker_redis"`
StorageRedisConfig RedisConfig `mapstructure:"storage_redis"` StorageRedisConfig RedisConfig `mapstructure:"storage_redis"`
AsyncTaskConfig AsyncTaskConfig `mapstructure:"async_task"` AsyncTaskConfig AsyncTaskConfig `mapstructure:"async_task"`

View File

@ -1,31 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadAndInitConfigReadsNestedDataRTManualSync(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
contents := []byte(`
dataRT:
manual_sync:
protocol_cl3611_url: "http://127.0.0.1:9001"
protocol_104_url: "http://127.0.0.1:9002"
api_path: "/api/manual"
timeout: 3s
`)
require.NoError(t, os.WriteFile(configPath, contents, 0o600))
cfg := ReadAndInitConfig(filepath.Dir(configPath), "config", "yaml")
assert.Equal(t, "http://127.0.0.1:9001", cfg.DataRTConfig.ManualSync.ProtocolCL3611URL)
assert.Equal(t, "http://127.0.0.1:9002", cfg.DataRTConfig.ManualSync.Protocol104URL)
assert.Equal(t, "/api/manual", cfg.DataRTConfig.ManualSync.APIPath)
assert.Equal(t, 3*time.Second, cfg.DataRTConfig.ManualSync.Timeout)
}

View File

@ -17,16 +17,3 @@ const (
// MeasurementModeAutomatic indicates that the measurement runs automatically. // MeasurementModeAutomatic indicates that the measurement runs automatically.
MeasurementModeAutomatic int16 = 1 MeasurementModeAutomatic int16 = 1
) )
const (
// MeasurementTypeTelemetry represents TM (遥测).
MeasurementTypeTelemetry int16 = 0
// MeasurementTypeTelesignal represents TS (遥信).
MeasurementTypeTelesignal int16 = 1
// MeasurementTypeTelecommand represents TC (遥控).
MeasurementTypeTelecommand int16 = 2
// MeasurementTypeTeleadjusting represents TA (遥调).
MeasurementTypeTeleadjusting int16 = 3
// MeasurementTypeSetpoint represents SP (定值).
MeasurementTypeSetpoint int16 = 4
)

View File

@ -3,9 +3,7 @@ package constants
import "strings" import "strings"
const ComponentParameterAttributeGroup = "component" var supportedParameterTableSuffixes = [...]string{
var supportedDynamicParameterAttributeGroups = [...]string{
"base_extend", "base_extend",
"rated", "rated",
"setup", "setup",
@ -16,32 +14,10 @@ var supportedDynamicParameterAttributeGroups = [...]string{
"behavior", "behavior",
} }
// IsSupportedParameterAttributeGroup reports whether token6 identifies a
// parameter attribute group supported by the data-object APIs.
func IsSupportedParameterAttributeGroup(group string) bool {
if group == ComponentParameterAttributeGroup {
return true
}
for _, supportedGroup := range supportedDynamicParameterAttributeGroups {
if group == supportedGroup {
return true
}
}
return false
}
// SupportedDynamicParameterAttributeGroups returns the token6 values backed by
// project_manager dynamic tables.
func SupportedDynamicParameterAttributeGroups() []string {
groups := make([]string, len(supportedDynamicParameterAttributeGroups))
copy(groups, supportedDynamicParameterAttributeGroups[:])
return groups
}
// IsSupportedParameterTableName reports whether a dynamic parameter table has // IsSupportedParameterTableName reports whether a dynamic parameter table has
// one of the supported attribute-group suffixes. // one of the supported attribute-group suffixes.
func IsSupportedParameterTableName(tableName string) bool { func IsSupportedParameterTableName(tableName string) bool {
for _, suffix := range supportedDynamicParameterAttributeGroups { for _, suffix := range supportedParameterTableSuffixes {
if strings.HasSuffix(tableName, "_"+suffix) { if strings.HasSuffix(tableName, "_"+suffix) {
return true return true
} }

View File

@ -4,24 +4,4 @@ package constants
const ( const (
// RedisSearchDictName define redis search dictionary name // RedisSearchDictName define redis search dictionary name
RedisSearchDictName = "search_suggestions_dict" RedisSearchDictName = "search_suggestions_dict"
// RedisParameterDataObjectKeySet tracks parameter hashes created during
// startup so stale parameter data-object keys can be removed safely.
RedisParameterDataObjectKeySet = "modelrt:parameter-data-object:keys"
// RedisMeasurementDataObjectKeySet tracks measurement hashes created during
// startup so stale measurement data-object keys can be removed safely.
RedisMeasurementDataObjectKeySet = "modelrt:measurement-data-object:keys"
// RedisParameterDataObjectAliasKeySet tracks parameter alias string keys.
RedisParameterDataObjectAliasKeySet = "modelrt:parameter-data-object:alias-keys"
// RedisParameterDataObjectAliasPrefix prefixes parameter token aliases.
RedisParameterDataObjectAliasPrefix = "modelrt:data-object:alias:parameter:"
// RedisMeasurementDataObjectAliasKeySet tracks measurement alias string keys.
RedisMeasurementDataObjectAliasKeySet = "modelrt:measurement-data-object:alias-keys"
// RedisMeasurementDataObjectAliasPrefix prefixes measurement token aliases.
RedisMeasurementDataObjectAliasPrefix = "modelrt:data-object:alias:measurement:"
) )

View File

@ -1,80 +0,0 @@
package database
import (
"context"
"fmt"
"modelRT/constants"
"modelRT/model"
modelsql "modelRT/sql"
"gorm.io/gorm"
)
// QueryMeasurementInitializationRecords loads every measurement that can be
// addressed through the seven-part, four-part, and two-part token forms.
func QueryMeasurementInitializationRecords(ctx context.Context, db *gorm.DB) ([]model.MeasurementInitializationRecord, error) {
if db == nil {
return nil, fmt.Errorf("postgres client is nil")
}
var records []model.MeasurementInitializationRecord
if err := db.WithContext(ctx).
Raw(compactMeasurementSQL(modelsql.MeasurementInitializationRows)).
Scan(&records).Error; err != nil {
return nil, fmt.Errorf("query measurement initialization records: %w", err)
}
if err := validateMeasurementInitializationRecords(records); err != nil {
return nil, fmt.Errorf("validate measurement initialization records: %w", err)
}
return records, nil
}
func validateMeasurementInitializationRecords(records []model.MeasurementInitializationRecord) error {
for _, record := range records {
if record.MeasurementID <= 0 {
return fmt.Errorf("measurement %q has invalid id %d", record.MeasurementTag, record.MeasurementID)
}
if record.ComponentUUID == "" {
return fmt.Errorf("measurement %q has empty component uuid", record.MeasurementTag)
}
if record.GridTag == "" ||
record.ZoneTag == "" ||
record.StationTag == "" ||
record.ComponentNSPath == "" ||
record.ComponentTag == "" ||
record.MeasurementTag == "" {
return fmt.Errorf("measurement %d contains an empty data-object token segment", record.MeasurementID)
}
if record.MeasurementMode != constants.MeasurementModeManual &&
record.MeasurementMode != constants.MeasurementModeAutomatic {
return fmt.Errorf(
"measurement %q mode must be %d or %d, got %d",
record.MeasurementTag,
constants.MeasurementModeManual,
constants.MeasurementModeAutomatic,
record.MeasurementMode,
)
}
if _, err := model.MeasurementTypeString(record.MeasurementType); err != nil {
return fmt.Errorf("measurement %q: %w", record.MeasurementTag, err)
}
if record.MeasurementSize <= 0 {
return fmt.Errorf(
"measurement %q window size must be greater than 0, got %d",
record.MeasurementTag,
record.MeasurementSize,
)
}
if record.MeasurementDataSource == nil {
return fmt.Errorf("measurement %q has null data_source", record.MeasurementTag)
}
if record.MeasurementEventPlan == nil {
return fmt.Errorf("measurement %q has null event_plan", record.MeasurementTag)
}
if record.MeasurementBinding == nil {
return fmt.Errorf("measurement %q has null binding", record.MeasurementTag)
}
}
return nil
}

View File

@ -1,138 +0,0 @@
package database
import (
"context"
"strings"
"testing"
"modelRT/model"
modelsql "modelRT/sql"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestMeasurementInitializationSQLJoinsRequiredHierarchy(t *testing.T) {
statement := compactMeasurementSQL(modelsql.MeasurementInitializationRows)
assert.Contains(t, statement, "component.station_id = station.id")
assert.Contains(t, statement, "measurement.component_uuid = component.global_uuid")
assert.Contains(t, statement, "bay.bay_uuid = measurement.bay_uuid")
assert.Contains(t, statement, "measurement.type AS measurement_type")
assert.Contains(t, statement, "measurement.tag <> ''")
assert.NotContains(t, strings.ToLower(statement), "dev_")
}
func TestQueryMeasurementInitializationRecords(t *testing.T) {
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
require.NoError(t, err)
mock.ExpectQuery(`(?s)SELECT.*FROM public\.grid.*INNER JOIN public\.zone.*INNER JOIN public\.station.*INNER JOIN public\.component.*INNER JOIN public\.measurement.*INNER JOIN public\.bay`).
WillReturnRows(measurementInitializationRows().
AddRow(
"grid000",
"zone000",
"station000",
"component-uuid",
"nspath",
"component",
int64(10),
"IA_rms",
"A相保护电流有效值",
int16(0),
int16(1),
1,
`{"type":1,"io_address":{"channel":"TM1"}}`,
`{}`,
`{"ct":{"ratio":1250}}`,
))
records, err := QueryMeasurementInitializationRecords(context.Background(), db)
require.NoError(t, err)
require.Len(t, records, 1)
assert.Equal(t, int64(10), records[0].MeasurementID)
assert.Equal(t, "IA_rms", records[0].MeasurementTag)
assert.Equal(t, int16(0), records[0].MeasurementType)
assert.Equal(t, float64(1), records[0].MeasurementDataSource["type"])
require.NoError(t, mock.ExpectationsWereMet())
}
func TestValidateMeasurementInitializationRecords(t *testing.T) {
record := validMeasurementInitializationRecord()
invalidMode := record
invalidMode.MeasurementMode = 3
err := validateMeasurementInitializationRecords([]model.MeasurementInitializationRecord{invalidMode})
require.Error(t, err)
assert.Contains(t, err.Error(), "mode must be 0 or 1")
emptySegment := record
emptySegment.ComponentNSPath = ""
err = validateMeasurementInitializationRecords([]model.MeasurementInitializationRecord{emptySegment})
require.Error(t, err)
assert.Contains(t, err.Error(), "empty data-object token segment")
nullDataSource := record
nullDataSource.MeasurementDataSource = nil
err = validateMeasurementInitializationRecords([]model.MeasurementInitializationRecord{nullDataSource})
require.Error(t, err)
assert.Contains(t, err.Error(), "null data_source")
invalidType := record
invalidType.MeasurementType = -1
err = validateMeasurementInitializationRecords([]model.MeasurementInitializationRecord{invalidType})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported measurement type -1")
invalidSize := record
invalidSize.MeasurementSize = 0
err = validateMeasurementInitializationRecords([]model.MeasurementInitializationRecord{invalidSize})
require.Error(t, err)
assert.Contains(t, err.Error(), "window size must be greater than 0")
}
func measurementInitializationRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{
"grid_tag",
"zone_tag",
"station_tag",
"component_uuid",
"component_nspath",
"component_tag",
"measurement_id",
"measurement_tag",
"measurement_name",
"measurement_type",
"measurement_mode",
"measurement_size",
"measurement_data_source",
"measurement_event_plan",
"measurement_binding",
})
}
func validMeasurementInitializationRecord() model.MeasurementInitializationRecord {
return model.MeasurementInitializationRecord{
GridTag: "grid",
ZoneTag: "zone",
StationTag: "station",
ComponentUUID: "component-uuid",
ComponentNSPath: "nspath",
ComponentTag: "component",
MeasurementID: 1,
MeasurementTag: "measurement",
MeasurementType: 0,
MeasurementMode: 1,
MeasurementSize: 1,
MeasurementDataSource: map[string]any{},
MeasurementEventPlan: map[string]any{},
MeasurementBinding: map[string]any{},
}
}

View File

@ -1,141 +0,0 @@
// Package database define database operation functions
package database
import (
"context"
"fmt"
"modelRT/constants"
"modelRT/model"
"modelRT/sql"
"gorm.io/gorm"
)
type parameterInitializationRoute struct {
TableName string `gorm:"column:name"`
ModelName string `gorm:"column:tag"`
AttributeGroup string `gorm:"column:group_name"`
}
// QueryParameterInitializationRecords loads every parameter accepted by the
// data-object query API. Dynamic parameters are resolved through
// project_manager; component parameters are read directly from component.
func QueryParameterInitializationRecords(ctx context.Context, db *gorm.DB) ([]model.ParameterInitializationRecord, error) {
if db == nil {
return nil, fmt.Errorf("postgres client is nil")
}
var routes []parameterInitializationRoute
if err := db.WithContext(ctx).
Raw(
compactParameterSQL(sql.ParameterInitializationRoutes),
constants.SupportedDynamicParameterAttributeGroups(),
).
Scan(&routes).Error; err != nil {
return nil, fmt.Errorf("query parameter initialization routes: %w", err)
}
if err := validateParameterInitializationRoutes(routes); err != nil {
return nil, err
}
records := make([]model.ParameterInitializationRecord, 0)
for _, route := range routes {
quotedTableName := `"` + route.TableName + `"`
query := compactParameterSQL(
fmt.Sprintf(sql.DynamicParameterInitializationRows, quotedTableName),
)
var tableRecords []model.ParameterInitializationRecord
if err := db.WithContext(ctx).
Raw(query, route.TableName, route.ModelName, route.AttributeGroup).
Scan(&tableRecords).Error; err != nil {
return nil, fmt.Errorf(
"query parameter initialization table %q for model %q group %q: %w",
route.TableName,
route.ModelName,
route.AttributeGroup,
err,
)
}
if err := validateParameterInitializationRecords(tableRecords); err != nil {
return nil, fmt.Errorf("validate parameter initialization table %q: %w", route.TableName, err)
}
records = append(records, tableRecords...)
}
var componentRecords []model.ParameterInitializationRecord
if err := db.WithContext(ctx).
Raw(compactParameterSQL(sql.ComponentParameterInitializationRows)).
Scan(&componentRecords).Error; err != nil {
return nil, fmt.Errorf("query component parameter initialization records: %w", err)
}
if err := validateParameterInitializationRecords(componentRecords); err != nil {
return nil, fmt.Errorf("validate component parameter initialization records: %w", err)
}
records = append(records, componentRecords...)
return records, nil
}
func validateParameterInitializationRoutes(routes []parameterInitializationRoute) error {
seen := make(map[string]struct{}, len(routes))
for _, route := range routes {
if !validParameterTableName(route.TableName) {
return fmt.Errorf("project_manager contains unsupported parameter table name %q", route.TableName)
}
if !constants.IsSupportedParameterAttributeGroup(route.AttributeGroup) ||
route.AttributeGroup == constants.ComponentParameterAttributeGroup {
return fmt.Errorf("project_manager contains unsupported dynamic attribute group %q", route.AttributeGroup)
}
key := route.ModelName + "\x00" + route.AttributeGroup
if _, exists := seen[key]; exists {
return fmt.Errorf(
"model %q and attribute group %q match more than one project_manager record",
route.ModelName,
route.AttributeGroup,
)
}
seen[key] = struct{}{}
}
return nil
}
func validateParameterInitializationRecords(records []model.ParameterInitializationRecord) error {
for _, record := range records {
switch record.DynamicRecordCount {
case 1:
case 0:
return fmt.Errorf(
"component %q has no %q parameter record",
record.ComponentTag,
record.AttributeGroup,
)
default:
return fmt.Errorf(
"component %q has %d %q parameter records",
record.ComponentTag,
record.DynamicRecordCount,
record.AttributeGroup,
)
}
if record.AttributeName == "" || record.AttributeType == "" {
return fmt.Errorf(
"component %q group %q contains an invalid parameter column",
record.ComponentTag,
record.AttributeGroup,
)
}
switch record.DescriptionCount {
case 0:
return fmt.Errorf("parameter description not found for attribute %q", record.AttributeName)
case 1:
if !record.Description.Valid {
return fmt.Errorf("parameter description is null for attribute %q", record.AttributeName)
}
default:
return fmt.Errorf("ambiguous parameter description for attribute %q", record.AttributeName)
}
}
return nil
}

View File

@ -1,158 +0,0 @@
// Package database define database operation functions
package database
import (
"context"
"database/sql"
"database/sql/driver"
"strings"
"testing"
"modelRT/constants"
"modelRT/model"
modelsql "modelRT/sql"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestParameterInitializationSQLUsesStationIDAndExcludesItFromComponentAttributes(t *testing.T) {
dynamicSQL := compactParameterSQL(modelsql.DynamicParameterInitializationRows)
componentSQL := compactParameterSQL(modelsql.ComponentParameterInitializationRows)
assert.Contains(t, dynamicSQL, "component.station_id = station.id")
assert.Contains(t, componentSQL, "component.station_id = station.id")
assert.Contains(t, componentSQL, "to_jsonb(component) - 'station_id'")
assert.Contains(t, dynamicSQL, "component.nspath <> ''")
assert.Contains(t, dynamicSQL, "component.tag <> ''")
assert.Contains(t, componentSQL, "component.nspath <> ''")
assert.Contains(t, componentSQL, "component.tag <> ''")
assert.NotContains(t, strings.ToLower(componentSQL), "component.station = station.tagname")
}
func TestQueryParameterInitializationRecordsJoinsHierarchyAndDynamicTable(t *testing.T) {
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
require.NoError(t, err)
groups := constants.SupportedDynamicParameterAttributeGroups()
routeArgs := make([]driver.Value, len(groups))
for index, group := range groups {
routeArgs[index] = group
}
mock.ExpectQuery(`(?s)SELECT name, tag, group_name.*FROM project_manager.*WHERE group_name IN`).
WithArgs(routeArgs...).
WillReturnRows(sqlmock.NewRows([]string{"name", "tag", "group_name"}).
AddRow("cable_cable_demo_base_extend", "cable_demo", "base_extend"))
mock.ExpectQuery(`(?s)WITH dynamic_rows AS.*FROM public\."cable_cable_demo_base_extend".*FROM public\.grid.*INNER JOIN public\.zone.*INNER JOIN public\.station.*INNER JOIN public\.component.*INNER JOIN public\.project_manager.*jsonb_each`).
WithArgs("cable_cable_demo_base_extend", "cable_demo", "base_extend").
WillReturnRows(parameterInitializationRows().
AddRow(
"grid000",
"zone000",
"station000",
true,
"component-uuid",
"nspath",
"component",
"base_extend",
"vnom_kv",
"220.0",
"DOUBLE PRECISION",
"额定电压",
int64(1),
int64(1),
))
mock.ExpectQuery(`(?s)SELECT.*FROM public\.grid.*INNER JOIN public\.zone.*INNER JOIN public\.station.*INNER JOIN public\.component.*jsonb_each`).
WillReturnRows(parameterInitializationRows().
AddRow(
"grid000",
"zone000",
"station000",
true,
"component-uuid",
"nspath",
"component",
"component",
"description",
`"组件"`,
"CHARACTER VARYING(512)",
"组件名称",
int64(1),
int64(1),
))
records, err := QueryParameterInitializationRecords(context.Background(), db)
require.NoError(t, err)
require.Len(t, records, 2)
assert.Equal(t, "vnom_kv", records[0].AttributeName)
assert.Equal(t, "description", records[1].AttributeName)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestValidateParameterInitializationRoutesRejectsAmbiguousMapping(t *testing.T) {
routes := []parameterInitializationRoute{
{TableName: "cable_demo_stable", ModelName: "cable_demo", AttributeGroup: "stable"},
{TableName: "cable_other_stable", ModelName: "cable_demo", AttributeGroup: "stable"},
}
err := validateParameterInitializationRoutes(routes)
require.Error(t, err)
assert.Contains(t, err.Error(), "more than one project_manager record")
}
func TestValidateParameterInitializationRecordsEnforcesDescriptionAndRowUniqueness(t *testing.T) {
validRecord := modelParameterInitializationRecordForTest()
missingDescription := validRecord
missingDescription.Description = sql.NullString{}
missingDescription.DescriptionCount = 0
err := validateParameterInitializationRecords([]model.ParameterInitializationRecord{missingDescription})
require.Error(t, err)
assert.Contains(t, err.Error(), "description not found")
duplicateRow := validRecord
duplicateRow.DynamicRecordCount = 2
err = validateParameterInitializationRecords([]model.ParameterInitializationRecord{duplicateRow})
require.Error(t, err)
assert.Contains(t, err.Error(), "has 2")
}
func parameterInitializationRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{
"grid_tag",
"zone_tag",
"station_tag",
"station_is_local",
"component_uuid",
"component_nspath",
"component_tag",
"attribute_group",
"attribute_name",
"attribute_value",
"attribute_type",
"description",
"description_count",
"dynamic_record_count",
})
}
func modelParameterInitializationRecordForTest() model.ParameterInitializationRecord {
return model.ParameterInitializationRecord{
ComponentTag: "component",
AttributeGroup: "stable",
AttributeName: "attribute",
AttributeType: "INTEGER",
Description: sql.NullString{String: "属性", Valid: true},
DescriptionCount: 1,
DynamicRecordCount: 1,
}
}

View File

@ -439,10 +439,10 @@ go run deploy/redis-test-data/measurments-recommend/measurement_injection.go
| | `station_id` | 项目所操作的默认变电站 `ID`。 | `1` | | | `station_id` | 项目所操作的默认变电站 `ID`。 | `1` |
| **Service Config** | `service_name` | 服务名称,用于日志、监控等标识。 | `"modelRT"` | | **Service Config** | `service_name` | 服务名称,用于日志、监控等标识。 | `"modelRT"` |
| | `secret_key` | 服务内部使用的秘钥,用于签名或认证。 | `"modelrt_key"` | | | `secret_key` | 服务内部使用的秘钥,用于签名或认证。 | `"modelrt_key"` |
| **DataRT Manual Sync** | `manual_sync.protocol_cl3611_url` | CL3611 协议服务地址。 | `"http://127.0.0.1:9001"` | | **DataRT API** | `host` | 外部 `DataRT` 服务的主机地址。 | `"http://127.0.0.1"` |
| | `manual_sync.protocol_104_url` | IEC 60870-5-104 协议服务地址。 | `"http://127.0.0.1:9002"` | | | `port` | `DataRT` 服务的端口号。 | `8888` |
| | `manual_sync.api_path` | 手动测量值及模式同步 API 路径。 | `"/api/manual"` | | | `polling_api` | 轮询数据的 `API` 路径。 | `"datart/getPointData"` |
| | `manual_sync.timeout` | 同步请求超时时间。 | `"3s"` | | | `polling_api_method` | 调用该 `API` 使用的 `HTTP` 方法。 | `"GET"` |
#### 3.2 编译 ModelRT 服务 #### 3.2 编译 ModelRT 服务
@ -759,94 +759,6 @@ kubectl delete -f deploy/k8s/pg-service.yaml \
-f deploy/k8s/pg-configmap.yaml -f deploy/k8s/pg-configmap.yaml
``` ```
#### 4.5 部署 MongoDB 并创建应用用户
使用以下清单部署 MongoDB
```bash
kubectl apply -f deploy/k8s/mongodb-secret.yaml
kubectl apply -f deploy/k8s/mongodb-pvc.yaml
kubectl apply -f deploy/k8s/mongodb-statefulset.yaml
kubectl apply -f deploy/k8s/mongodb-service.yaml
```
等待 MongoDB Pod 就绪:
```bash
kubectl wait --for=condition=ready pod/mongodb-0 --timeout=180s
```
MongoDB 首次初始化时会根据 `mongodb-secret.yaml` 创建 `admin` 管理员。Pod 就绪后,以管理员身份在 `admin` 认证库中创建应用用户 `coslight`,并授予其对 `eventdb` 的读写和数据库管理权限:
```bash
kubectl exec mongodb-0 -- mongosh \
-u admin \
-p coslight \
--authenticationDatabase admin \
--quiet \
--eval '
const adminDb = db.getSiblingDB("admin");
adminDb.createUser({
user: "coslight",
pwd: "coslight",
roles: [
{ role: "readWrite", db: "eventdb" },
{ role: "dbAdmin", db: "eventdb" }
]
});
'
```
> **注意:** `use admin``mongosh` 的交互式命令,不应在 `--eval` 脚本中使用。这里通过 `db.getSiblingDB("admin")` 明确指定用户所属的认证库。用户存储在 `admin` 库中,因此应用连接时必须将认证库配置为 `admin`
检查用户是否创建成功:
```bash
kubectl exec mongodb-0 -- mongosh \
-u admin \
-p coslight \
--authenticationDatabase admin \
--quiet \
--eval 'printjson(db.getSiblingDB("admin").getUser("coslight"));'
```
使用 `coslight` 用户连接并验证 `eventdb` 权限:
```bash
kubectl exec mongodb-0 -- mongosh \
-u coslight \
-p coslight \
--authenticationDatabase admin \
--quiet \
--eval '
const eventDb = db.getSiblingDB("eventdb");
eventDb.__permission_check.insertOne({ checkedAt: new Date() });
eventDb.__permission_check.deleteMany({});
printjson({ ok: 1, database: eventDb.getName() });
'
```
如果 `coslight` 用户已经存在,重复执行 `createUser` 会返回 `User already exists`。需要重置密码或修正角色时,使用:
```bash
kubectl exec mongodb-0 -- mongosh \
-u admin \
-p coslight \
--authenticationDatabase admin \
--quiet \
--eval '
db.getSiblingDB("admin").updateUser("coslight", {
pwd: "coslight",
roles: [
{ role: "readWrite", db: "eventdb" },
{ role: "dbAdmin", db: "eventdb" }
]
});
'
```
> **安全提示:** 示例使用仓库当前的测试密码。生产环境应修改管理员和应用用户密码,并避免在命令行或版本库中保存明文凭据。
### 5\. 部署 ModelRTKubernetes ### 5\. 部署 ModelRTKubernetes
所有资源部署在 `default` 命名空间YAML 文件位于 `deploy/k8s/` 所有资源部署在 `default` 命名空间YAML 文件位于 `deploy/k8s/`

View File

@ -80,9 +80,7 @@ data:
deploy_env: "development" deploy_env: "development"
dataRT: dataRT:
# manual measurement synchronization endpoints host: "http://127.0.0.1"
manual_sync: port: 8888
protocol_cl3611_url: "http://protocol-cl3611-service:9001" polling_api: "datart/getPointData"
protocol_104_url: "http://protocol-104-service:9002" polling_api_method: "GET"
api_path: "/api/manual"
timeout: 3s

View File

@ -61,9 +61,8 @@ func ProcessMeasurements(measurements []orm.Measurement) map[string]CalculationR
station, _ := ioAddress["station"].(string) station, _ := ioAddress["station"].(string)
device, _ := ioAddress["device"].(string) device, _ := ioAddress["device"].(string)
channel, _ := ioAddress["channel"].(string) channel, _ := ioAddress["channel"].(string)
option, _ := ioAddress["option"].(string)
result := strings.ToLower(fmt.Sprintf("%s:%s:phasor:%s:%s", station, device, channel, option)) result := strings.ToLower(fmt.Sprintf("%s:%s:phasor:%s", station, device, channel))
if measurement.EventPlan == nil { if measurement.EventPlan == nil {
continue continue
} }

View File

@ -4,7 +4,6 @@ package diagram
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@ -19,73 +18,40 @@ type RedisClient struct {
// greatest numeric timestamp. Measurement ZSets currently store timestamp in // greatest numeric timestamp. Measurement ZSets currently store timestamp in
// member and measurement value in score. // member and measurement value in score.
func (rc *RedisClient) QueryLatestMeasurementValue(ctx context.Context, key string) (float64, error) { func (rc *RedisClient) QueryLatestMeasurementValue(ctx context.Context, key string) (float64, error) {
values, err := rc.QueryLatestMeasurementValues(ctx, key, 1)
if err != nil {
return 0, err
}
return values[0], nil
}
// QueryLatestMeasurementValues returns up to size scores ordered by their
// numeric member timestamps from newest to oldest.
func (rc *RedisClient) QueryLatestMeasurementValues(ctx context.Context, key string, size int) ([]float64, error) {
if rc.Client == nil { if rc.Client == nil {
return nil, fmt.Errorf("redis client is not initialized") return 0, fmt.Errorf("redis client is not initialized")
}
if size <= 0 {
return nil, fmt.Errorf("measurement window size must be greater than 0, got %d", size)
} }
members, err := rc.Client.ZRangeWithScores(ctx, key, 0, -1).Result() members, err := rc.Client.ZRangeWithScores(ctx, key, 0, -1).Result()
if err != nil { if err != nil {
return nil, err return 0, err
} }
return latestMeasurementValues(members, key, size) return latestMeasurementValue(members, key)
} }
func latestMeasurementValue(members []redis.Z, key string) (float64, error) { func latestMeasurementValue(members []redis.Z, key string) (float64, error) {
values, err := latestMeasurementValues(members, key, 1)
if err != nil {
return 0, err
}
return values[0], nil
}
func latestMeasurementValues(members []redis.Z, key string, size int) ([]float64, error) {
if size <= 0 {
return nil, fmt.Errorf("measurement window size must be greater than 0, got %d", size)
}
if len(members) == 0 { if len(members) == 0 {
return nil, fmt.Errorf("real-time measurement value not found for key %q", key) return 0, fmt.Errorf("real-time measurement value not found for key %q", key)
} }
type timestampedValue struct { var latestTimestamp int64
timestamp int64 var latestValue float64
value float64 found := false
}
values := make([]timestampedValue, 0, len(members))
for _, member := range members { for _, member := range members {
timestamp, err := strconv.ParseInt(fmt.Sprint(member.Member), 10, 64) timestamp, err := strconv.ParseInt(fmt.Sprint(member.Member), 10, 64)
if err != nil { if err != nil {
continue continue
} }
values = append(values, timestampedValue{timestamp: timestamp, value: member.Score}) if !found || timestamp > latestTimestamp {
latestTimestamp = timestamp
latestValue = member.Score
found = true
} }
if len(values) == 0 {
return nil, fmt.Errorf("real-time measurement timestamps are invalid for key %q", key)
} }
if !found {
sort.Slice(values, func(i, j int) bool { return 0, fmt.Errorf("real-time measurement timestamps are invalid for key %q", key)
return values[i].timestamp > values[j].timestamp
})
if size > len(values) {
size = len(values)
} }
result := make([]float64, size) return latestValue, nil
for index := range size {
result[index] = values[index].value
}
return result, nil
} }
// NewRedisClient define func of new redis client instance // NewRedisClient define func of new redis client instance

View File

@ -26,32 +26,3 @@ func TestLatestMeasurementValueRejectsMissingOrInvalidTimestamps(t *testing.T) {
_, err = latestMeasurementValue([]redis.Z{{Member: "invalid", Score: 1}}, "measurement-key") _, err = latestMeasurementValue([]redis.Z{{Member: "invalid", Score: 1}}, "measurement-key")
require.Error(t, err) require.Error(t, err)
} }
func TestLatestMeasurementValuesReturnsNewestWindow(t *testing.T) {
values, err := latestMeasurementValues([]redis.Z{
{Member: "100", Score: 10},
{Member: "400", Score: 40},
{Member: "invalid", Score: 999},
{Member: "200", Score: 20},
{Member: "300", Score: 30},
}, "measurement-key", 3)
require.NoError(t, err)
assert.Equal(t, []float64{40, 30, 20}, values)
}
func TestLatestMeasurementValuesReturnsAvailableWindow(t *testing.T) {
values, err := latestMeasurementValues([]redis.Z{
{Member: "100", Score: 10},
{Member: "200", Score: 20},
}, "measurement-key", 5)
require.NoError(t, err)
assert.Equal(t, []float64{20, 10}, values)
}
func TestLatestMeasurementValuesRejectsInvalidSize(t *testing.T) {
_, err := latestMeasurementValues([]redis.Z{{Member: "100", Score: 10}}, "measurement-key", 0)
require.Error(t, err)
assert.Contains(t, err.Error(), "window size must be greater than 0")
}

View File

@ -3,27 +3,26 @@ package handler
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"modelRT/common" "modelRT/common"
"modelRT/common/errcode" "modelRT/common/errcode"
"modelRT/constants" "modelRT/constants"
"modelRT/database"
"modelRT/diagram" "modelRT/diagram"
"modelRT/logger" "modelRT/logger"
"modelRT/model" "modelRT/model"
"modelRT/orm" "modelRT/orm"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
) )
// DataObjectAttributeQueryHandler define data object attribute value query process API // DataObjectAttributeQueryHandler define data object attribute value query process API
func DataObjectAttributeQueryHandler(c *gin.Context) { func DataObjectAttributeQueryHandler(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
pgClient := database.GetPostgresDBClient()
token, field, err := parseDataObjectAttributeQuery(c) token, field, err := parseDataObjectAttributeQuery(c)
if err != nil { if err != nil {
@ -45,24 +44,66 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
return return
} }
value, err := queryDataObjectAttributeValue( var parameter *database.ParameterDataObject
ctx, var measurement *orm.Measurement
dataObjectType, var measurementComponent *orm.Component
token, switch dataObjectType {
field, case constants.DataObjectTypeParameter:
loadDataObjectHashField, // 参量支持两种形式token4.token5.token6.token7与token1.token2.token3.token4.token5.token6.token7
loadMeasurementValueMetadata, parameter, err = database.QueryParameterByDataObjectToken(ctx, pgClient, token)
queryMeasurementRealtimeValue,
)
if err != nil { if err != nil {
if isDataObjectTokenNotFound(err) { if errors.Is(err, common.ErrInvalidParameterToken) ||
logger.Warn(ctx, "query data-object token from redis failed", "token", token, "field", field, "error", err) errors.Is(err, common.ErrParameterTokenNotFound) ||
errors.Is(err, common.ErrAmbiguousParameterToken) {
logger.Warn(ctx, "validate parameter token failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return return
} }
logger.Error(ctx, "query data-object attribute from redis failed", "token", token, "field", field, "error", err) logger.Error(ctx, "query parameter token from postgres failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeServerError, dataObjectAttributeFailureMessage(dataObjectType), nil) renderRespFailure(c, constants.RespCodeServerError, "validate parameter token failed", nil)
return
}
case constants.DataObjectTypeMeasurement:
// 量测支持token1.token2.token3.token4.token5.token6.token7、token4.token5.token6.token7、token4.token7
measurement, measurementComponent, err = database.QueryMeasurementByDataObjectToken(ctx, pgClient, token)
if err != nil {
if errors.Is(err, common.ErrInvalidMeasurementToken) ||
errors.Is(err, common.ErrMeasurementTokenNotFound) ||
errors.Is(err, common.ErrAmbiguousMeasurementToken) {
logger.Warn(ctx, "validate measurement token failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
logger.Error(ctx, "query measurement token from postgres failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "validate measurement token failed", nil)
return
}
}
switch dataObjectType {
case constants.DataObjectTypeParameter:
value, err := buildParameterAttributeValue(
ctx,
field,
parameter,
func(ctx context.Context, parameter *database.ParameterDataObject) (any, error) {
return database.QueryParameterDataObjectValue(ctx, pgClient, parameter)
},
func(ctx context.Context, attributeName string) (string, error) {
return database.QueryParameterAttributeDescription(ctx, pgClient, attributeName)
},
)
if err != nil {
if errors.Is(err, common.ErrUnsupportedParameterField) {
logger.Warn(ctx, "query unsupported parameter field", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
logger.Error(ctx, "build parameter attribute value failed", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "query parameter attribute failed", nil)
return return
} }
@ -73,9 +114,42 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
Msg: errcode.ErrProcessSuccess.Msg(), Msg: errcode.ErrProcessSuccess.Msg(),
Value: value, Value: value,
} }
renderRespSuccess(c, constants.RespCodeSuccess, dataObjectAttributeSuccessMessage(dataObjectType), map[string]any{ renderRespSuccess(c, constants.RespCodeSuccess, "query parameter attribute success", map[string]any{
"attributes": []dataObjectAttributeQueryResult{result}, "attributes": []dataObjectAttributeQueryResult{result},
}) })
case constants.DataObjectTypeMeasurement:
value, err := buildMeasurementAttributeValue(
ctx,
field,
measurement,
measurementComponent,
queryMeasurementRealtimeValue,
)
if err != nil {
if errors.Is(err, common.ErrUnsupportedMeasurementField) {
logger.Warn(ctx, "query unsupported measurement field", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
logger.Error(ctx, "build measurement attribute value failed", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "query measurement attribute failed", nil)
return
}
result := dataObjectAttributeQueryResult{
Token: token,
Field: field,
Code: errcode.ErrProcessSuccess.Code(),
Msg: errcode.ErrProcessSuccess.Msg(),
Value: value,
}
renderRespSuccess(c, constants.RespCodeSuccess, "query measurement attribute success", map[string]any{
"attributes": []dataObjectAttributeQueryResult{result},
})
default:
renderRespFailure(c, constants.RespCodeInvalidParams, "invalid data object type", nil)
}
} }
func parseDataObjectAttributeQuery(c *gin.Context) (string, string, error) { func parseDataObjectAttributeQuery(c *gin.Context) (string, string, error) {
@ -91,11 +165,11 @@ func parseDataObjectAttributeQuery(c *gin.Context) (string, string, error) {
return token, field, nil return token, field, nil
} }
type dataObjectHashFieldLoader func(context.Context, constants.DataObjectType, string, string) (string, error) type measurementValueLoader func(context.Context, orm.JSONMap) (any, error)
type measurementValueMetadataLoader func(context.Context, string) (orm.JSONMap, int, error) type parameterValueLoader func(context.Context, *database.ParameterDataObject) (any, error)
type measurementValueLoader func(context.Context, orm.JSONMap, int) (any, error) type parameterDescriptionLoader func(context.Context, string) (string, error)
var measurementDataObjectFields = map[string]struct{}{ var measurementDataObjectFields = map[string]struct{}{
"value": {}, "value": {},
@ -146,227 +220,116 @@ func validateDataObjectField(dataObjectType constants.DataObjectType, field stri
} }
} }
func queryDataObjectAttributeValue( func buildParameterAttributeValue(
ctx context.Context, ctx context.Context,
dataObjectType constants.DataObjectType,
token string,
field string, field string,
loadHashField dataObjectHashFieldLoader, parameter *database.ParameterDataObject,
loadMeasurementMetadata measurementValueMetadataLoader, loadValue parameterValueLoader,
loadMeasurementValue measurementValueLoader, loadDescription parameterDescriptionLoader,
) (any, error) { ) (any, error) {
if dataObjectType == constants.DataObjectTypeMeasurement && field == "value" { if parameter == nil {
if loadMeasurementMetadata == nil { return nil, fmt.Errorf("parameter data object is nil")
return nil, fmt.Errorf("measurement value metadata loader is nil")
}
if loadMeasurementValue == nil {
return nil, fmt.Errorf("measurement value loader is nil")
}
dataSource, size, err := loadMeasurementMetadata(ctx, token)
if err != nil {
return nil, err
}
return loadMeasurementValue(ctx, dataSource, size)
} }
if loadHashField == nil { component := parameter.Component
return nil, fmt.Errorf("data-object hash field loader is nil") switch field {
case "value":
if loadValue == nil {
return nil, fmt.Errorf("parameter value loader is nil")
} }
rawValue, err := loadHashField(ctx, dataObjectType, token, field) return loadValue(ctx, parameter)
if err != nil { case "meta":
return nil, err return "PARAM", nil
case "type":
return parameter.AttributeType, nil
case "name":
return strings.Join([]string{
component.NSPath,
component.Tag,
parameter.AttributeGroup,
parameter.AttributeName,
}, "."), nil
case "description":
if loadDescription == nil {
return nil, fmt.Errorf("parameter description loader is nil")
} }
if dataObjectType == constants.DataObjectTypeParameter && field == "value" { return loadDescription(ctx, parameter.AttributeName)
attributeType, err := loadHashField(ctx, dataObjectType, token, "type") case "id":
if err != nil { return strings.Join([]string{
return nil, err component.GridName,
component.ZoneName,
component.StationName,
component.NSPath,
component.Tag,
parameter.AttributeGroup,
parameter.AttributeName,
}, "."), nil
default:
return nil, fmt.Errorf("%w: %s", common.ErrUnsupportedParameterField, field)
} }
return decodeParameterHashValue(rawValue, attributeType)
}
return decodeDataObjectHashField(dataObjectType, field, rawValue)
} }
func loadDataObjectHashField( func buildMeasurementAttributeValue(
ctx context.Context, ctx context.Context,
dataObjectType constants.DataObjectType,
token string,
field string, field string,
) (string, error) { measurement *orm.Measurement,
rdb := diagram.GetRedisClientInstance() component *orm.Component,
if rdb == nil { loadValue measurementValueLoader,
return "", fmt.Errorf("redis client is not initialized")
}
canonicalKey, err := model.ResolveDataObjectRedisKey(ctx, rdb, dataObjectType, token)
if err != nil {
return "", err
}
value, err := rdb.HGet(ctx, canonicalKey, field).Result()
if errors.Is(err, redis.Nil) {
return "", fmt.Errorf("canonical redis data-object hash %q does not contain field %q", canonicalKey, field)
}
if err != nil {
return "", fmt.Errorf("query canonical redis hash %q field %q: %w", canonicalKey, field, err)
}
return value, nil
}
func loadMeasurementValueMetadata(ctx context.Context, token string) (orm.JSONMap, int, error) {
rdb := diagram.GetRedisClientInstance()
if rdb == nil {
return nil, 0, fmt.Errorf("redis client is not initialized")
}
canonicalKey, err := model.ResolveDataObjectRedisKey(ctx, rdb, constants.DataObjectTypeMeasurement, token)
if err != nil {
return nil, 0, err
}
values, err := rdb.HMGet(ctx, canonicalKey, "data_source", "size").Result()
if err != nil {
return nil, 0, fmt.Errorf("query canonical redis hash %q measurement value metadata: %w", canonicalKey, err)
}
if len(values) != 2 {
return nil, 0, fmt.Errorf("canonical redis hash %q returned %d measurement metadata fields", canonicalKey, len(values))
}
if values[0] == nil || values[1] == nil {
missingFields := make([]string, 0, 2)
if values[0] == nil {
missingFields = append(missingFields, "data_source")
}
if values[1] == nil {
missingFields = append(missingFields, "size")
}
return nil, 0, fmt.Errorf(
"canonical redis measurement hash %q does not contain field(s) %s",
canonicalKey,
strings.Join(missingFields, ", "),
)
}
rawDataSource, ok := values[0].(string)
if !ok {
return nil, 0, fmt.Errorf("canonical redis measurement hash %q data_source has type %T", canonicalKey, values[0])
}
var dataSource orm.JSONMap
if err := json.Unmarshal([]byte(rawDataSource), &dataSource); err != nil {
return nil, 0, fmt.Errorf("decode measurement data_source from canonical redis hash %q: %w", canonicalKey, err)
}
rawSize, ok := values[1].(string)
if !ok {
return nil, 0, fmt.Errorf("canonical redis measurement hash %q size has type %T", canonicalKey, values[1])
}
size, err := strconv.Atoi(rawSize)
if err != nil {
return nil, 0, fmt.Errorf("decode measurement size %q: %w", rawSize, err)
}
if size <= 0 {
return nil, 0, fmt.Errorf("measurement window size must be greater than 0, got %d", size)
}
return dataSource, size, nil
}
func decodeDataObjectHashField(
dataObjectType constants.DataObjectType,
field string,
rawValue string,
) (any, error) { ) (any, error) {
if dataObjectType != constants.DataObjectTypeMeasurement { if measurement == nil {
return rawValue, nil return nil, fmt.Errorf("measurement is nil")
}
if component == nil {
return nil, fmt.Errorf("measurement component is nil")
} }
switch field { switch field {
case "value":
if loadValue == nil {
return nil, fmt.Errorf("measurement value loader is nil")
}
return loadValue(ctx, measurement.DataSource)
case "mode": case "mode":
value, err := strconv.ParseInt(rawValue, 10, 16) return measurement.Mode, nil
if err != nil { case "meta":
return nil, fmt.Errorf("decode measurement mode %q: %w", rawValue, err) return "MEASUREMENT", nil
} case "type":
return int16(value), nil return model.MeasurementTypeFromDataSource(measurement.DataSource)
case "name":
// The resolved measurement and component prove that token4.token7 exists.
return component.NSPath + "." + measurement.Tag, nil
case "description":
return measurement.Name, nil
case "id":
return strings.Join([]string{
component.GridName,
component.ZoneName,
component.StationName,
component.NSPath,
component.Tag,
"bay",
measurement.Tag,
}, "."), nil
case "size": case "size":
value, err := strconv.Atoi(rawValue) return measurement.Size, nil
if err != nil { case "data_source":
return nil, fmt.Errorf("decode measurement size %q: %w", rawValue, err) return measurement.DataSource, nil
} case "event_plan":
return value, nil return measurement.EventPlan, nil
case "data_source", "event_plan", "binding": case "binding":
return decodeRedisJSON(rawValue) return measurement.Binding, nil
default: default:
return rawValue, nil return nil, fmt.Errorf("%w: %s", common.ErrUnsupportedMeasurementField, field)
} }
} }
func decodeParameterHashValue(rawValue, attributeType string) (any, error) { func queryMeasurementRealtimeValue(ctx context.Context, dataSource orm.JSONMap) (any, error) {
normalizedType := strings.ToUpper(strings.TrimSpace(attributeType))
if rawValue == "null" {
return nil, nil
}
switch {
case normalizedType == "BOOLEAN":
value, err := strconv.ParseBool(rawValue)
if err != nil {
return nil, fmt.Errorf("decode parameter boolean value %q: %w", rawValue, err)
}
return value, nil
case normalizedType == "SMALLINT",
normalizedType == "INTEGER",
normalizedType == "BIGINT":
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return nil, fmt.Errorf("decode parameter integer value %q: %w", rawValue, err)
}
return value, nil
case normalizedType == "REAL",
normalizedType == "DOUBLE PRECISION",
strings.HasPrefix(normalizedType, "NUMERIC"),
strings.HasPrefix(normalizedType, "DECIMAL"):
if _, err := strconv.ParseFloat(rawValue, 64); err != nil {
return nil, fmt.Errorf("decode parameter numeric value %q: %w", rawValue, err)
}
return json.Number(rawValue), nil
case normalizedType == "JSON",
normalizedType == "JSONB",
strings.HasSuffix(normalizedType, "[]"):
return decodeRedisJSON(rawValue)
default:
return rawValue, nil
}
}
func decodeRedisJSON(rawValue string) (any, error) {
var value any
decoder := json.NewDecoder(strings.NewReader(rawValue))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("decode redis JSON value: %w", err)
}
return value, nil
}
func isDataObjectTokenNotFound(err error) bool {
return errors.Is(err, common.ErrParameterTokenNotFound) ||
errors.Is(err, common.ErrMeasurementTokenNotFound)
}
func dataObjectAttributeSuccessMessage(dataObjectType constants.DataObjectType) string {
if dataObjectType == constants.DataObjectTypeParameter {
return "query parameter attribute success"
}
return "query measurement attribute success"
}
func dataObjectAttributeFailureMessage(dataObjectType constants.DataObjectType) string {
if dataObjectType == constants.DataObjectTypeParameter {
return "query parameter attribute failed"
}
return "query measurement attribute failed"
}
func queryMeasurementRealtimeValue(ctx context.Context, dataSource orm.JSONMap, size int) (any, error) {
queryKey, err := model.GenerateMeasureIdentifier(dataSource) queryKey, err := model.GenerateMeasureIdentifier(dataSource)
if err != nil { if err != nil {
return nil, fmt.Errorf("generate measurement redis key: %w", err) return nil, fmt.Errorf("generate measurement redis key: %w", err)
} }
value, err := diagram.NewRedisClient().QueryLatestMeasurementValues(ctx, queryKey, size) value, err := diagram.NewRedisClient().QueryLatestMeasurementValue(ctx, queryKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("query real-time measurement value by key %q: %w", queryKey, err) return nil, fmt.Errorf("query real-time measurement value by key %q: %w", queryKey, err)
} }

View File

@ -2,14 +2,13 @@ package handler
import ( import (
"context" "context"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"modelRT/common" "modelRT/common"
"modelRT/constants" "modelRT/constants"
"modelRT/database"
"modelRT/orm" "modelRT/orm"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -118,156 +117,160 @@ func TestValidateDataObjectField(t *testing.T) {
} }
} }
func TestQueryParameterAttributeValueFromRedisHash(t *testing.T) { func TestBuildParameterAttributeValue(t *testing.T) {
fields := map[string]string{ parameter := &database.ParameterDataObject{
"value": "220.50", Component: orm.Component{
"type": "DOUBLE PRECISION", GridName: "grid000",
"name": "110kV_TV.cable_22.rated.rated_voltage", ZoneName: "zone000",
"description": "额定电压", StationName: "station000",
} NSPath: "110kV_TV",
loader := hashFieldLoaderForTest(fields) Tag: "cable_22",
value, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeParameter,
"parameter-token",
"value",
loader,
nil,
nil,
)
require.NoError(t, err)
assert.Equal(t, json.Number("220.50"), value)
description, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeParameter,
"parameter-token",
"description",
loader,
nil,
nil,
)
require.NoError(t, err)
assert.Equal(t, "额定电压", description)
}
func TestQueryMeasurementAttributeValueFromRedisHash(t *testing.T) {
fields := map[string]string{
"mode": "1",
"size": "10",
"name": "110kV_TV.IA_rms",
"data_source": `{"type":1,"io_address":{"channel":"tm1p"}}`,
"event_plan": `{"enabled":true}`,
}
loader := hashFieldLoaderForTest(fields)
mode, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeMeasurement,
"measurement-token",
"mode",
loader,
nil,
nil,
)
require.NoError(t, err)
assert.Equal(t, int16(1), mode)
eventPlan, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeMeasurement,
"measurement-token",
"event_plan",
loader,
nil,
nil,
)
require.NoError(t, err)
assert.Equal(t, map[string]any{"enabled": true}, eventPlan)
}
func TestQueryMeasurementRealtimeValueUsesDataSourceFromRedisHash(t *testing.T) {
dataSource := orm.JSONMap{
"type": float64(1),
"io_address": map[string]any{
"station": "001",
"channel": "tm1p",
}, },
AttributeGroup: "rated",
AttributeName: "rated_voltage",
AttributeType: "DOUBLE PRECISION",
} }
metadataLoader := func(_ context.Context, token string) (orm.JSONMap, int, error) { loader := func(_ context.Context, actual *database.ParameterDataObject) (any, error) {
assert.Equal(t, "measurement-token", token) assert.Same(t, parameter, actual)
return dataSource, 2, nil return float64(220), nil
} }
valueLoader := func(_ context.Context, dataSource orm.JSONMap, size int) (any, error) { descriptionLoader := func(_ context.Context, attributeName string) (string, error) {
assert.Equal(t, float64(1), dataSource["type"]) assert.Equal(t, "rated_voltage", attributeName)
assert.Equal(t, "001", dataSource["io_address"].(map[string]any)["station"]) return "额定电压", nil
assert.Equal(t, 2, size)
return []float64{220, 219.5}, nil
} }
value, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeMeasurement,
"measurement-token",
"value",
nil,
metadataLoader,
valueLoader,
)
require.NoError(t, err)
assert.Equal(t, []float64{220, 219.5}, value)
}
func TestQueryDataObjectAttributeValuePropagatesRedisTokenNotFound(t *testing.T) {
loader := func(context.Context, constants.DataObjectType, string, string) (string, error) {
return "", fmt.Errorf("%w: token", common.ErrParameterTokenNotFound)
}
_, err := queryDataObjectAttributeValue(
context.Background(),
constants.DataObjectTypeParameter,
"missing-token",
"name",
loader,
nil,
nil,
)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrParameterTokenNotFound)
assert.True(t, isDataObjectTokenNotFound(err))
}
func TestDecodeParameterHashValue(t *testing.T) {
tests := []struct { tests := []struct {
name string field string
rawValue string
attributeType string
expected any expected any
}{ }{
{name: "boolean", rawValue: "true", attributeType: "BOOLEAN", expected: true}, {field: "value", expected: float64(220)},
{name: "integer", rawValue: "42", attributeType: "INTEGER", expected: int64(42)}, {field: "meta", expected: "PARAM"},
{name: "numeric", rawValue: "1234567890.123456789", attributeType: "NUMERIC(30,9)", expected: json.Number("1234567890.123456789")}, {field: "type", expected: "DOUBLE PRECISION"},
{name: "jsonb", rawValue: `{"key":"value"}`, attributeType: "JSONB", expected: map[string]any{"key": "value"}}, {field: "name", expected: "110kV_TV.cable_22.rated.rated_voltage"},
{name: "string", rawValue: "cable", attributeType: "CHARACTER VARYING(64)", expected: "cable"}, {field: "description", expected: "额定电压"},
{name: "null", rawValue: "null", attributeType: "INTEGER", expected: nil}, {field: "id", expected: "grid000.zone000.station000.110kV_TV.cable_22.rated.rated_voltage"},
} }
for _, test := range tests { for _, tt := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(tt.field, func(t *testing.T) {
actual, err := decodeParameterHashValue(test.rawValue, test.attributeType) actual, err := buildParameterAttributeValue(
context.Background(),
tt.field,
parameter,
loader,
descriptionLoader,
)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, test.expected, actual) assert.Equal(t, tt.expected, actual)
}) })
} }
} }
func hashFieldLoaderForTest(fields map[string]string) dataObjectHashFieldLoader { func TestBuildParameterAttributeValueRejectsUnsupportedField(t *testing.T) {
return func(_ context.Context, _ constants.DataObjectType, _ string, field string) (string, error) { _, err := buildParameterAttributeValue(
value, exists := fields[field] context.Background(),
if !exists { "unknown",
return "", fmt.Errorf("field %q not found", field) &database.ParameterDataObject{},
nil,
nil,
)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrUnsupportedParameterField)
}
func TestBuildMeasurementAttributeValue(t *testing.T) {
dataSource := orm.JSONMap{
"type": float64(1),
"io_address": map[string]any{
"channel": "tm1p",
},
} }
return value, nil eventPlan := orm.JSONMap{"enabled": true}
binding := orm.JSONMap{"ct": map[string]any{"ratio": float64(2)}}
measurement := &orm.Measurement{
Tag: "IA_rms",
Name: "A相电流",
Mode: 1,
Size: 10,
DataSource: dataSource,
EventPlan: eventPlan,
Binding: binding,
}
component := &orm.Component{
GridName: "grid000",
ZoneName: "zone000",
StationName: "station000",
NSPath: "110kV_TV",
Tag: "cable_22",
}
loader := func(_ context.Context, source orm.JSONMap) (any, error) {
assert.Equal(t, dataSource, source)
return float64(220), nil
}
tests := []struct {
field string
expected any
}{
{field: "value", expected: float64(220)},
{field: "mode", expected: int16(1)},
{field: "meta", expected: "MEASUREMENT"},
{field: "type", expected: "TM"},
{field: "name", expected: "110kV_TV.IA_rms"},
{field: "description", expected: "A相电流"},
{field: "id", expected: "grid000.zone000.station000.110kV_TV.cable_22.bay.IA_rms"},
{field: "size", expected: 10},
{field: "data_source", expected: dataSource},
{field: "event_plan", expected: eventPlan},
{field: "binding", expected: binding},
}
for _, tt := range tests {
t.Run(tt.field, func(t *testing.T) {
actual, err := buildMeasurementAttributeValue(context.Background(), tt.field, measurement, component, loader)
require.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestBuildMeasurementAttributeValueRejectsUnsupportedField(t *testing.T) {
_, err := buildMeasurementAttributeValue(
context.Background(),
"unknown",
&orm.Measurement{},
&orm.Component{},
nil,
)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrUnsupportedMeasurementField)
}
func TestBuildMeasurementAttributeValueMode(t *testing.T) {
component := &orm.Component{}
tests := []struct {
name string
mode int16
expected int16
}{
{name: "collected value", mode: 1, expected: 1},
{name: "manually assigned value", mode: 0, expected: 0},
{name: "other positive mode", mode: 2, expected: 2},
{name: "negative mode", mode: -1, expected: -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := buildMeasurementAttributeValue(
context.Background(),
"mode",
&orm.Measurement{Mode: tt.mode},
component,
nil,
)
require.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
} }
} }

View File

@ -11,7 +11,6 @@ import (
"strings" "strings"
"time" "time"
"modelRT/client/manualsync"
"modelRT/common" "modelRT/common"
"modelRT/common/errcode" "modelRT/common/errcode"
"modelRT/constants" "modelRT/constants"
@ -20,7 +19,6 @@ import (
"modelRT/logger" "modelRT/logger"
"modelRT/model" "modelRT/model"
"modelRT/orm" "modelRT/orm"
redisrepository "modelRT/repository/redis"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@ -33,8 +31,6 @@ type dataObjectAttributeUpdateRequest struct {
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
} }
const redisChangeRestoreTimeout = 5 * time.Second
// DataObjectAttributeUpdateHandler updates the writable field of one data object. // DataObjectAttributeUpdateHandler updates the writable field of one data object.
func DataObjectAttributeUpdateHandler(c *gin.Context) { func DataObjectAttributeUpdateHandler(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
@ -65,50 +61,22 @@ func DataObjectAttributeUpdateHandler(c *gin.Context) {
} }
}() }()
redisClient := diagram.GetRedisClientInstance()
redisChanges := redisrepository.NewRedisChangeSet(redisClient)
canonicalRedisKey, err := model.ResolveDataObjectRedisKey(
ctx,
redisClient,
dataObjectType,
request.Token,
)
message := "data-object attribute update success" message := "data-object attribute update success"
var measurementResult measurementUpdateResult var measurementResult measurementUpdateResult
switch { switch dataObjectType {
case err != nil: case constants.DataObjectTypeParameter:
// The shared resolver error is handled by the common failure path below.
case dataObjectType == constants.DataObjectTypeParameter:
parameter, queryErr := database.QueryParameterByDataObjectToken(ctx, tx, request.Token) parameter, queryErr := database.QueryParameterByDataObjectToken(ctx, tx, request.Token)
if queryErr == nil { if queryErr == nil {
queryErr = database.UpdateParameterDataObjectValue(ctx, tx, parameter, value) queryErr = database.UpdateParameterDataObjectValue(ctx, tx, parameter, value)
} }
if queryErr == nil {
queryErr = redisChanges.AddHashChange(ctx, canonicalRedisKey, field, value)
}
err = queryErr err = queryErr
case dataObjectType == constants.DataObjectTypeMeasurement: case constants.DataObjectTypeMeasurement:
measurementResult, err = updateMeasurementDataObject(ctx, tx, request.Token, field, value, request.Data, measurementUpdateDependencies{ measurementResult, err = updateMeasurementDataObject(ctx, tx, request.Token, field, value, request.Data, measurementUpdateDependencies{
writeManualValueFunc: func(ctx context.Context, measurement *orm.Measurement, value float64, timestamp time.Time) error { writeManualValueFunc: writeMeasurementManualValue,
key, err := model.GenerateMeasureIdentifier(measurement.DataSource) updateDataRTFunc: callRealTimeDataWriteStopInterface,
if err != nil { startDataRTFunc: callRealTimeDataWriteStartInterface,
return fmt.Errorf("generate measurement redis key: %w", err) replaceRedisValueFunc: replaceMeasurementRedisValue,
}
return redisChanges.AddMeasurementValueChange(ctx, key, value, timestamp, false)
},
syncManualChangeFunc: manualsync.Sync,
replaceRedisValueFunc: func(ctx context.Context, measurement *orm.Measurement, value float64, timestamp time.Time) error {
key, err := model.GenerateMeasureIdentifier(measurement.DataSource)
if err != nil {
return fmt.Errorf("generate measurement redis key: %w", err)
}
return redisChanges.AddMeasurementValueChange(ctx, key, value, timestamp, true)
},
nowFunc: time.Now,
}) })
if err == nil && measurementResult.modeChanged {
err = redisChanges.AddHashChange(ctx, canonicalRedisKey, "mode", measurementResult.mode)
}
message = measurementResult.message message = measurementResult.message
default: default:
err = fmt.Errorf("unsupported data object type %q", dataObjectType) err = fmt.Errorf("unsupported data object type %q", dataObjectType)
@ -116,8 +84,8 @@ func DataObjectAttributeUpdateHandler(c *gin.Context) {
if err != nil { if err != nil {
_ = tx.Rollback().Error _ = tx.Rollback().Error
if measurementResult.recordFailureOnError { if measurementResult.recordFailure {
if logErr := database.AppendMeasurementValueOperation(ctx, database.GetPostgresDBClient(), measurementResult.measurementID, 1, measurementResult.value, measurementFailureTime(measurementResult)); logErr != nil { if logErr := database.AppendMeasurementValueOperation(ctx, database.GetPostgresDBClient(), measurementResult.measurementID, 1, measurementResult.value, time.Now().UTC()); logErr != nil {
logger.Error(ctx, "append failed measurement value operation failed", "measurement_id", measurementResult.measurementID, "error", logErr) logger.Error(ctx, "append failed measurement value operation failed", "measurement_id", measurementResult.measurementID, "error", logErr)
} }
} }
@ -130,29 +98,7 @@ func DataObjectAttributeUpdateHandler(c *gin.Context) {
return return
} }
if err := redisChanges.Apply(ctx); err != nil {
_ = tx.Rollback().Error
if measurementResult.recordFailureOnError {
if logErr := database.AppendMeasurementValueOperation(ctx, database.GetPostgresDBClient(), measurementResult.measurementID, 1, measurementResult.value, measurementFailureTime(measurementResult)); logErr != nil {
logger.Error(ctx, "append failed measurement value operation failed", "measurement_id", measurementResult.measurementID, "error", logErr)
}
}
logger.Error(ctx, "apply redis data-object changes failed", "token", request.Token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeFailed, err.Error(), nil)
return
}
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
revertCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), redisChangeRestoreTimeout)
defer cancel()
if redisErr := redisChanges.Revert(revertCtx); redisErr != nil {
logger.Error(ctx, "revert redis data-object changes failed", "token", request.Token, "field", field, "error", redisErr)
}
if measurementResult.recordFailureOnError {
if logErr := database.AppendMeasurementValueOperation(ctx, database.GetPostgresDBClient(), measurementResult.measurementID, 1, measurementResult.value, measurementFailureTime(measurementResult)); logErr != nil {
logger.Error(ctx, "append failed measurement value operation failed", "measurement_id", measurementResult.measurementID, "error", logErr)
}
}
logger.Error(ctx, "commit data-object update transaction failed", "token", request.Token, "field", field, "error", err) logger.Error(ctx, "commit data-object update transaction failed", "token", request.Token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "transaction commit failed", nil) renderRespFailure(c, constants.RespCodeServerError, "transaction commit failed", nil)
return return
@ -273,27 +219,24 @@ func parseMeasurementUpdateMode(raw json.RawMessage) (int16, error) {
return mode, nil return mode, nil
} }
type measurementManualValueWriter func(context.Context, *orm.Measurement, float64, time.Time) error type measurementManualValueWriter func(context.Context, *orm.Measurement, float64) error
type measurementManualChangeSyncer func(context.Context, orm.JSONMap, int16, *manualsync.SyntheticData) error type measurementDataRTUpdater func(context.Context, orm.JSONMap, *float64) error
type measurementRedisValueReplacer func(context.Context, *orm.Measurement, float64, time.Time) error type measurementRedisValueReplacer func(context.Context, *orm.Measurement, float64) error
type measurementUpdateDependencies struct { type measurementUpdateDependencies struct {
writeManualValueFunc measurementManualValueWriter writeManualValueFunc measurementManualValueWriter
syncManualChangeFunc measurementManualChangeSyncer updateDataRTFunc measurementDataRTUpdater
startDataRTFunc measurementDataRTUpdater
replaceRedisValueFunc measurementRedisValueReplacer replaceRedisValueFunc measurementRedisValueReplacer
nowFunc func() time.Time
} }
type measurementUpdateResult struct { type measurementUpdateResult struct {
message string message string
measurementID int64 measurementID int64
value float64 value float64
recordFailureOnError bool recordFailure bool
mode int16
modeChanged bool
operationTime time.Time
} }
func updateMeasurementDataObject( func updateMeasurementDataObject(
@ -328,7 +271,6 @@ func updateMeasurementDataObject(
if currentMode == targetAutomatic { if currentMode == targetAutomatic {
return measurementUpdateResult{message: fmt.Sprintf("measurement is already in %s mode", measurementModeName(mode))}, nil return measurementUpdateResult{message: fmt.Sprintf("measurement is already in %s mode", measurementModeName(mode))}, nil
} }
operationTime := measurementUpdateNow(dependencies)
var manualValue *float64 var manualValue *float64
if currentMode && mode == constants.MeasurementModeManual { if currentMode && mode == constants.MeasurementModeManual {
manualValue, err = parseOptionalMeasurementModeData(modeData) manualValue, err = parseOptionalMeasurementModeData(modeData)
@ -336,33 +278,34 @@ func updateMeasurementDataObject(
return measurementUpdateResult{}, err return measurementUpdateResult{}, err
} }
} }
if err := database.UpdateMeasurementModeWithOperation(ctx, tx, lockedMeasurement.ID, mode, operationTime); err != nil { if err := database.UpdateMeasurementModeWithOperation(ctx, tx, lockedMeasurement.ID, mode, time.Now().UTC()); err != nil {
return measurementUpdateResult{}, err return measurementUpdateResult{}, err
} }
syncManualMeasurementChange(
ctx,
dependencies.syncManualChangeFunc,
lockedMeasurement.ID,
lockedMeasurement.DataSource,
mode,
nil,
)
if currentMode && mode == constants.MeasurementModeManual { if currentMode && mode == constants.MeasurementModeManual {
if dependencies.updateDataRTFunc == nil {
return measurementUpdateResult{}, fmt.Errorf("measurement dataRT updater is nil")
}
if err := dependencies.updateDataRTFunc(ctx, lockedMeasurement.DataSource, nil); err != nil {
return measurementUpdateResult{}, fmt.Errorf("stop automatic measurement write to dataRT: %w", err)
}
if manualValue != nil { if manualValue != nil {
if dependencies.replaceRedisValueFunc == nil { if dependencies.replaceRedisValueFunc == nil {
return measurementUpdateResult{}, fmt.Errorf("measurement redis value replacer is nil") return measurementUpdateResult{}, fmt.Errorf("measurement redis value replacer is nil")
} }
if err := dependencies.replaceRedisValueFunc(ctx, &lockedMeasurement, *manualValue, operationTime); err != nil { if err := dependencies.replaceRedisValueFunc(ctx, &lockedMeasurement, *manualValue); err != nil {
return measurementUpdateResult{}, fmt.Errorf("replace measurement redis value: %w", err) return measurementUpdateResult{}, fmt.Errorf("replace measurement redis value: %w", err)
} }
} }
} }
return measurementUpdateResult{ if !currentMode && mode == constants.MeasurementModeAutomatic {
message: fmt.Sprintf("measurement mode changed to %s", measurementModeName(mode)), if dependencies.startDataRTFunc == nil {
mode: mode, return measurementUpdateResult{}, fmt.Errorf("measurement dataRT starter is nil")
modeChanged: true, }
operationTime: operationTime, if err := dependencies.startDataRTFunc(ctx, lockedMeasurement.DataSource, nil); err != nil {
}, nil return measurementUpdateResult{}, fmt.Errorf("start automatic measurement write to dataRT: %w", err)
}
}
return measurementUpdateResult{message: fmt.Sprintf("measurement mode changed to %s", measurementModeName(mode))}, nil
case "value": case "value":
currentMode, err := measurementModeIsAutomatic(lockedMeasurement.Mode) currentMode, err := measurementModeIsAutomatic(lockedMeasurement.Mode)
if err != nil { if err != nil {
@ -375,94 +318,32 @@ func updateMeasurementDataObject(
if !ok { if !ok {
return measurementUpdateResult{}, fmt.Errorf("measurement value has invalid type %T", value) return measurementUpdateResult{}, fmt.Errorf("measurement value has invalid type %T", value)
} }
operationTime := measurementUpdateNow(dependencies)
failureResult := measurementUpdateResult{ failureResult := measurementUpdateResult{
measurementID: lockedMeasurement.ID, measurementID: lockedMeasurement.ID,
value: manualValue, value: manualValue,
recordFailureOnError: true, recordFailure: true,
operationTime: operationTime,
} }
if dependencies.writeManualValueFunc == nil { if dependencies.writeManualValueFunc == nil {
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(fmt.Errorf("measurement manual value writer is nil")) return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(fmt.Errorf("measurement manual value writer is nil"))
} }
if err := dependencies.writeManualValueFunc(ctx, &lockedMeasurement, manualValue, operationTime); err != nil { if err := dependencies.writeManualValueFunc(ctx, &lockedMeasurement, manualValue); err != nil {
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err) return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
} }
sample := manualsync.SyntheticData{Time: operationTime.UnixNano(), Value: manualValue} if dependencies.updateDataRTFunc == nil {
syncManualMeasurementChange( return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(fmt.Errorf("measurement dataRT updater is nil"))
ctx, }
dependencies.syncManualChangeFunc, if err := dependencies.updateDataRTFunc(ctx, lockedMeasurement.DataSource, &manualValue); err != nil {
lockedMeasurement.ID,
lockedMeasurement.DataSource,
constants.MeasurementModeManual,
&sample,
)
if err := database.AppendMeasurementValueOperation(ctx, tx, lockedMeasurement.ID, 0, manualValue, operationTime); err != nil {
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err) return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
} }
failureResult.message = "measurement manual value updated" if err := database.AppendMeasurementValueOperation(ctx, tx, lockedMeasurement.ID, 0, manualValue, time.Now().UTC()); err != nil {
return failureResult, nil return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
}
return measurementUpdateResult{message: "measurement manual value updated"}, nil
default: default:
return measurementUpdateResult{}, fmt.Errorf("unsupported measurement update field %q", field) return measurementUpdateResult{}, fmt.Errorf("unsupported measurement update field %q", field)
} }
} }
// TODO: This synchronous best-effort manual synchronization is not necessarily
// the final implementation. Its current behavior is to keep the local update
// successful when the downstream request fails (or the client is unavailable),
// log the error, and permanently drop that synchronization event without retry.
// The HTTP timeout still adds latency to the update request, and downstream state
// may diverge from local state. Revisit asynchronous delivery or a durable outbox
// if delivery reliability or request latency becomes important.
func syncManualMeasurementChange(
ctx context.Context,
syncer measurementManualChangeSyncer,
measurementID int64,
dataSource orm.JSONMap,
mode int16,
sample *manualsync.SyntheticData,
) {
if syncer == nil {
logManualMeasurementSyncError(ctx, "manual measurement synchronization skipped because sync client is unavailable",
"measurement_id", measurementID,
"mode", mode,
"has_data", sample != nil,
)
return
}
if err := syncer(ctx, dataSource, mode, sample); err != nil {
logManualMeasurementSyncError(ctx, "manual measurement synchronization failed; local update will continue",
"measurement_id", measurementID,
"mode", mode,
"has_data", sample != nil,
"error", err,
)
}
}
func logManualMeasurementSyncError(ctx context.Context, message string, fields ...any) {
// The application initializes logging before serving requests. This guard keeps
// the best-effort path safe in isolated unit tests and other pre-init callers.
if logger.GetLoggerInstance() == nil {
return
}
logger.Error(ctx, message, fields...)
}
func measurementUpdateNow(dependencies measurementUpdateDependencies) time.Time {
if dependencies.nowFunc != nil {
return dependencies.nowFunc().UTC()
}
return time.Now().UTC()
}
func measurementFailureTime(result measurementUpdateResult) time.Time {
if !result.operationTime.IsZero() {
return result.operationTime
}
return time.Now().UTC()
}
func parseOptionalMeasurementModeData(raw json.RawMessage) (*float64, error) { func parseOptionalMeasurementModeData(raw json.RawMessage) (*float64, error) {
trimmed := bytes.TrimSpace(raw) trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@ -501,3 +382,44 @@ func isInvalidDataObjectUpdateError(err error) bool {
errors.Is(err, common.ErrMeasurementTokenNotFound) || errors.Is(err, common.ErrMeasurementTokenNotFound) ||
errors.Is(err, common.ErrAmbiguousMeasurementToken) errors.Is(err, common.ErrAmbiguousMeasurementToken)
} }
func writeMeasurementManualValue(ctx context.Context, measurement *orm.Measurement, value float64) error {
key, err := model.GenerateMeasureIdentifier(measurement.DataSource)
if err != nil {
return fmt.Errorf("generate measurement redis key: %w", err)
}
zset, err := diagram.NewRedisZSet(ctx, key, 0, false)
if err != nil {
return fmt.Errorf("create measurement redis zset: %w", err)
}
if err := zset.ZADD(key, value, strconv.FormatInt(time.Now().UnixNano(), 10)); err != nil {
return fmt.Errorf("write manual measurement value to redis: %w", err)
}
return nil
}
func callRealTimeDataWriteStopInterface(_ context.Context, _ orm.JSONMap, _ *float64) error {
// TODO: call the dataRT HTTP API. A nil value stops automatic writes;
// a non-nil value writes the supplied manual measurement value.
return nil
}
func callRealTimeDataWriteStartInterface(_ context.Context, _ orm.JSONMap, _ *float64) error {
// TODO: call the dataRT HTTP API to start automatic measurement writes.
return nil
}
func replaceMeasurementRedisValue(ctx context.Context, measurement *orm.Measurement, value float64) error {
key, err := model.GenerateMeasureIdentifier(measurement.DataSource)
if err != nil {
return fmt.Errorf("generate measurement redis key: %w", err)
}
zset, err := diagram.NewRedisZSet(ctx, key, 0, false)
if err != nil {
return fmt.Errorf("create measurement redis zset: %w", err)
}
if err := zset.ZREPLACE(key, value, strconv.FormatInt(time.Now().UnixNano(), 10)); err != nil {
return fmt.Errorf("replace manual measurement value in redis: %w", err)
}
return nil
}

View File

@ -5,9 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"testing" "testing"
"time"
"modelRT/client/manualsync"
"modelRT/common/errcode" "modelRT/common/errcode"
"modelRT/constants" "modelRT/constants"
"modelRT/orm" "modelRT/orm"
@ -28,7 +26,6 @@ func TestValidateDataObjectAttributeUpdateParameterGroups(t *testing.T) {
"craft", "craft",
"integrity", "integrity",
"behavior", "behavior",
"base_extend",
} }
for _, group := range groups { for _, group := range groups {
@ -49,7 +46,7 @@ func TestValidateDataObjectAttributeUpdateParameterGroups(t *testing.T) {
} }
func TestValidateDataObjectAttributeUpdateRejectsUnsupportedParameterGroups(t *testing.T) { func TestValidateDataObjectAttributeUpdateRejectsUnsupportedParameterGroups(t *testing.T) {
for _, group := range []string{"component"} { for _, group := range []string{"component", "base_extend"} {
t.Run(group, func(t *testing.T) { t.Run(group, func(t *testing.T) {
_, _, _, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{ _, _, _, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
Token: fmt.Sprintf("nspath.component.%s.attribute", group), Token: fmt.Sprintf("nspath.component.%s.attribute", group),
@ -195,11 +192,10 @@ func TestUpdateMeasurementDataObjectLocksRowAndUpdatesMode(t *testing.T) {
startCalled := false startCalled := false
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{ result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{
syncManualChangeFunc: func(_ context.Context, dataSource orm.JSONMap, mode int16, sample *manualsync.SyntheticData) error { startDataRTFunc: func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
startCalled = true startCalled = true
assert.Equal(t, float64(1), dataSource["type"]) assert.Equal(t, float64(1), dataSource["type"])
assert.Equal(t, constants.MeasurementModeAutomatic, mode) assert.Nil(t, value)
assert.Nil(t, sample)
return nil return nil
}, },
}) })
@ -210,7 +206,7 @@ func TestUpdateMeasurementDataObjectLocksRowAndUpdatesMode(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet()) require.NoError(t, mock.ExpectationsWereMet())
} }
func TestUpdateMeasurementModeToAutomaticContinuesWhenManualSyncFails(t *testing.T) { func TestUpdateMeasurementModeToAutomaticReturnsErrorWhenDataRTStartFails(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t) db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB() defer closeDB()
@ -223,14 +219,13 @@ func TestUpdateMeasurementModeToAutomaticContinuesWhenManualSyncFails(t *testing
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback() mock.ExpectRollback()
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{ _, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{
syncManualChangeFunc: func(context.Context, orm.JSONMap, int16, *manualsync.SyntheticData) error { startDataRTFunc: func(context.Context, orm.JSONMap, *float64) error {
return fmt.Errorf("dataRT unavailable") return fmt.Errorf("dataRT unavailable")
}, },
}) })
require.NoError(t, err) require.Error(t, err)
assert.True(t, result.modeChanged) assert.Contains(t, err.Error(), "start automatic measurement write")
assert.Equal(t, constants.MeasurementModeAutomatic, result.mode)
require.NoError(t, tx.Rollback().Error) require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet()) require.NoError(t, mock.ExpectationsWereMet())
} }
@ -268,14 +263,13 @@ func TestUpdateMeasurementModeToManualWithoutDataOnlyStopsDataRT(t *testing.T) {
stopCalled := false stopCalled := false
replaceCalled := false replaceCalled := false
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, nil, measurementUpdateDependencies{ result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, nil, measurementUpdateDependencies{
syncManualChangeFunc: func(_ context.Context, dataSource orm.JSONMap, mode int16, sample *manualsync.SyntheticData) error { updateDataRTFunc: func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
stopCalled = true stopCalled = true
assert.Equal(t, float64(1), dataSource["type"]) assert.Equal(t, float64(1), dataSource["type"])
assert.Equal(t, constants.MeasurementModeManual, mode) assert.Nil(t, value)
assert.Nil(t, sample)
return nil return nil
}, },
replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64, time.Time) error { replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64) error {
replaceCalled = true replaceCalled = true
return nil return nil
}, },
@ -303,13 +297,12 @@ func TestUpdateMeasurementModeToManualReplacesRedisValueWhenDataProvided(t *test
callOrder := make([]string, 0, 2) callOrder := make([]string, 0, 2)
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`0`), measurementUpdateDependencies{ result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`0`), measurementUpdateDependencies{
syncManualChangeFunc: func(_ context.Context, _ orm.JSONMap, mode int16, sample *manualsync.SyntheticData) error { updateDataRTFunc: func(_ context.Context, _ orm.JSONMap, value *float64) error {
callOrder = append(callOrder, "sync-mode") callOrder = append(callOrder, "stop-dataRT")
assert.Equal(t, constants.MeasurementModeManual, mode) assert.Nil(t, value)
assert.Nil(t, sample)
return nil return nil
}, },
replaceRedisValueFunc: func(_ context.Context, measurement *orm.Measurement, value float64, _ time.Time) error { replaceRedisValueFunc: func(_ context.Context, measurement *orm.Measurement, value float64) error {
callOrder = append(callOrder, "replace-redis") callOrder = append(callOrder, "replace-redis")
assert.Equal(t, int64(10), measurement.ID) assert.Equal(t, int64(10), measurement.ID)
assert.Equal(t, float64(0), value) assert.Equal(t, float64(0), value)
@ -317,13 +310,13 @@ func TestUpdateMeasurementModeToManualReplacesRedisValueWhenDataProvided(t *test
}, },
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []string{"sync-mode", "replace-redis"}, callOrder) assert.Equal(t, []string{"stop-dataRT", "replace-redis"}, callOrder)
assert.Contains(t, result.message, "manual") assert.Contains(t, result.message, "manual")
require.NoError(t, tx.Rollback().Error) require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet()) require.NoError(t, mock.ExpectationsWereMet())
} }
func TestUpdateMeasurementModeToManualStillUpdatesRedisWhenManualSyncFails(t *testing.T) { func TestUpdateMeasurementModeToManualDoesNotTouchRedisWhenDataRTStopFails(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t) db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB() defer closeDB()
@ -337,20 +330,19 @@ func TestUpdateMeasurementModeToManualStillUpdatesRedisWhenManualSyncFails(t *te
mock.ExpectRollback() mock.ExpectRollback()
replaceCalled := false replaceCalled := false
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`15.2`), measurementUpdateDependencies{ _, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`15.2`), measurementUpdateDependencies{
syncManualChangeFunc: func(_ context.Context, _ orm.JSONMap, mode int16, sample *manualsync.SyntheticData) error { updateDataRTFunc: func(_ context.Context, _ orm.JSONMap, value *float64) error {
assert.Equal(t, constants.MeasurementModeManual, mode) assert.Nil(t, value)
assert.Nil(t, sample)
return fmt.Errorf("dataRT unavailable") return fmt.Errorf("dataRT unavailable")
}, },
replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64, time.Time) error { replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64) error {
replaceCalled = true replaceCalled = true
return nil return nil
}, },
}) })
require.NoError(t, err) require.Error(t, err)
assert.True(t, replaceCalled) assert.Contains(t, err.Error(), "stop automatic measurement write")
assert.True(t, result.modeChanged) assert.False(t, replaceCalled)
require.NoError(t, tx.Rollback().Error) require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet()) require.NoError(t, mock.ExpectationsWereMet())
} }
@ -402,63 +394,29 @@ func TestUpdateMeasurementDataObjectWritesValueInManualMode(t *testing.T) {
mock.ExpectRollback() mock.ExpectRollback()
called := false called := false
operationTime := time.Date(2026, time.August, 4, 10, 0, 0, 123, time.UTC) writer := func(_ context.Context, measurement *orm.Measurement, value float64) error {
writer := func(_ context.Context, measurement *orm.Measurement, value float64, timestamp time.Time) error {
called = true called = true
assert.Equal(t, int64(10), measurement.ID) assert.Equal(t, int64(10), measurement.ID)
assert.Equal(t, float64(15.2), value) assert.Equal(t, float64(15.2), value)
assert.Equal(t, operationTime, timestamp)
return nil return nil
} }
dataRTCalled := false dataRTCalled := false
dataRTWriter := func(_ context.Context, dataSource orm.JSONMap, mode int16, sample *manualsync.SyntheticData) error { dataRTWriter := func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
dataRTCalled = true dataRTCalled = true
assert.Equal(t, constants.MeasurementModeManual, mode) require.NotNil(t, value)
require.NotNil(t, sample) assert.Equal(t, float64(15.2), *value)
assert.Equal(t, float64(15.2), sample.Value)
assert.Equal(t, operationTime.UnixNano(), sample.Time)
assert.Equal(t, float64(1), dataSource["type"]) assert.Equal(t, float64(1), dataSource["type"])
return nil return nil
} }
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{ result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{
writeManualValueFunc: writer, writeManualValueFunc: writer,
syncManualChangeFunc: dataRTWriter, updateDataRTFunc: dataRTWriter,
nowFunc: func() time.Time { return operationTime },
}) })
require.NoError(t, err) require.NoError(t, err)
assert.True(t, called) assert.True(t, called)
assert.True(t, dataRTCalled) assert.True(t, dataRTCalled)
assert.Contains(t, result.message, "updated") assert.Contains(t, result.message, "updated")
assert.True(t, result.recordFailureOnError) assert.False(t, result.recordFailure)
assert.Equal(t, int64(10), result.measurementID)
assert.Equal(t, float64(15.2), result.value)
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementDataObjectContinuesValueUpdateWhenManualSyncFails(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, constants.MeasurementModeManual)
mock.ExpectExec(`UPDATE "measurement" SET "operations"=.*WHERE id = \$3`).
WithArgs(sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{
writeManualValueFunc: func(context.Context, *orm.Measurement, float64, time.Time) error {
return nil
},
syncManualChangeFunc: func(context.Context, orm.JSONMap, int16, *manualsync.SyntheticData) error {
return fmt.Errorf("manual sync unavailable")
},
})
require.NoError(t, err)
assert.Contains(t, result.message, "updated")
require.NoError(t, tx.Rollback().Error) require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet()) require.NoError(t, mock.ExpectationsWereMet())
} }
@ -474,14 +432,14 @@ func TestUpdateMeasurementDataObjectReturnsFailureResultAndAppError(t *testing.T
mock.ExpectRollback() mock.ExpectRollback()
writeErr := fmt.Errorf("write value failed") writeErr := fmt.Errorf("write value failed")
writer := func(context.Context, *orm.Measurement, float64, time.Time) error { return writeErr } writer := func(context.Context, *orm.Measurement, float64) error { return writeErr }
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{ result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{
writeManualValueFunc: writer, writeManualValueFunc: writer,
}) })
require.Error(t, err) require.Error(t, err)
assert.ErrorIs(t, err, errcode.ErrMeasurementValueUpdateFailed) assert.ErrorIs(t, err, errcode.ErrMeasurementValueUpdateFailed)
assert.ErrorIs(t, err, writeErr) assert.ErrorIs(t, err, writeErr)
assert.True(t, result.recordFailureOnError) assert.True(t, result.recordFailure)
assert.Equal(t, int64(10), result.measurementID) assert.Equal(t, int64(10), result.measurementID)
assert.Equal(t, float64(15.2), result.value) assert.Equal(t, float64(15.2), result.value)
require.NoError(t, tx.Rollback().Error) require.NoError(t, tx.Rollback().Error)

73
main.go
View File

@ -14,7 +14,6 @@ import (
"syscall" "syscall"
"time" "time"
"modelRT/client/manualsync"
"modelRT/config" "modelRT/config"
"modelRT/constants" "modelRT/constants"
"modelRT/database" "modelRT/database"
@ -99,11 +98,10 @@ func main() {
logger.InitLoggerInstance(modelRTConfig.LoggerConfig) logger.InitLoggerInstance(modelRTConfig.LoggerConfig)
defer logger.GetLoggerInstance().Sync() defer logger.GetLoggerInstance().Sync()
baseCtx := context.Background()
// init OTel TracerProvider // init OTel TracerProvider
tp, tpErr := middleware.InitTracerProvider(context.Background(), modelRTConfig) tp, tpErr := middleware.InitTracerProvider(context.Background(), modelRTConfig)
if tpErr != nil { if tpErr != nil {
logger.Error(baseCtx, "init OTLP tracer provider failed, tracing disabled", "error", tpErr) log.Printf("warn: OTLP tracer init failed, tracing disabled: %v", tpErr)
} }
if tp != nil { if tp != nil {
defer func() { defer func() {
@ -113,7 +111,7 @@ func main() {
}() }()
} }
ctx, startupSpan := otel.Tracer("modelRT/main").Start(baseCtx, "startup") ctx, startupSpan := otel.Tracer("modelRT/main").Start(context.Background(), "startup")
defer startupSpan.End() defer startupSpan.End()
hostName, err := os.Hostname() hostName, err := os.Hostname()
@ -128,16 +126,6 @@ func main() {
panic(err) panic(err)
} }
manualSyncClient, err := manualsync.NewClient(modelRTConfig.DataRTConfig.ManualSync)
if err != nil {
// TODO: This is a best-effort integration and may not be the final design.
// The current behavior lets modelRT start without a manual-sync client;
// affected updates still succeed and log an error while sync events are lost.
// Revisit fail-fast validation if this downstream service becomes mandatory.
logger.Error(ctx, "init manual measurement sync client failed", "error", err)
}
manualsync.SetDefaultSyncer(manualSyncClient)
// init postgresDBClient // init postgresDBClient
postgresDBClient = database.InitPostgresDBInstance(ctx, modelRTConfig.PostgresDBURI) postgresDBClient = database.InitPostgresDBInstance(ctx, modelRTConfig.PostgresDBURI)
@ -203,7 +191,7 @@ func main() {
// async push task message to rabbitMQ // async push task message to rabbitMQ
go task.PushTaskToRabbitMQ(ctx, modelRTConfig.RabbitMQConfig, task.TaskMsgChan) go task.PushTaskToRabbitMQ(ctx, modelRTConfig.RabbitMQConfig, task.TaskMsgChan)
if err := postgresDBClient.WithContext(ctx).Transaction(func(tx *gorm.DB) error { postgresDBClient.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// load circuit diagram from postgres // load circuit diagram from postgres
// componentTypeMap, err := database.QueryCircuitDiagramComponentFromDB(cancelCtx, tx, parsePool) // componentTypeMap, err := database.QueryCircuitDiagramComponentFromDB(cancelCtx, tx, parsePool)
// if err != nil { // if err != nil {
@ -213,80 +201,67 @@ func main() {
cacheMap, err := model.GetNSpathToIsLocalMap(ctx, postgresDBClient) cacheMap, err := model.GetNSpathToIsLocalMap(ctx, postgresDBClient)
if err != nil { if err != nil {
return fmt.Errorf("get nspath to is_local map: %w", err) logger.Error(ctx, "get nspath to is_local map failed", "error", err)
panic(err)
} }
model.NSPathToIsLocalMap = cacheMap model.NSPathToIsLocalMap = cacheMap
err = model.CleanupRecommendRedisCache(ctx) err = model.CleanupRecommendRedisCache(ctx)
if err != nil { if err != nil {
return fmt.Errorf("clean up component measurement and attribute group: %w", err) logger.Error(ctx, "clean up component measurement and attribute group failed", "error", err)
panic(err)
} }
measurementSet, err := database.GetFullMeasurementSet(ctx, postgresDBClient) measurementSet, err := database.GetFullMeasurementSet(ctx, postgresDBClient)
if err != nil { if err != nil {
return fmt.Errorf("generate component measurement group: %w", err) logger.Error(ctx, "generate component measurement group failed", "error", err)
panic(err)
} }
fullParentPath, isLocalParentPath, err := model.TraverseMeasurementGroupTables(ctx, *measurementSet) fullParentPath, isLocalParentPath, err := model.TraverseMeasurementGroupTables(ctx, *measurementSet)
if err != nil { if err != nil {
return fmt.Errorf("store component measurement group into redis: %w", err) logger.Error(ctx, "store component measurement group into redis failed", "error", err)
panic(err)
} }
compAttrSet, err := database.GenAllAttributeMap(tx) compAttrSet, err := database.GenAllAttributeMap(tx)
if err != nil { if err != nil {
return fmt.Errorf("generate component attribute group: %w", err) logger.Error(ctx, "generate component attribute group failed", "error", err)
panic(err)
} }
err = model.TraverseAttributeGroupTables(ctx, tx, fullParentPath, isLocalParentPath, compAttrSet) err = model.TraverseAttributeGroupTables(ctx, tx, fullParentPath, isLocalParentPath, compAttrSet)
if err != nil { if err != nil {
return fmt.Errorf("store component attribute group into redis: %w", err) logger.Error(ctx, "store component attribute group into redis failed", "error", err)
panic(err)
} }
componentColumnNames, err := database.QueryComponentColumnNames(ctx, tx) componentColumnNames, err := database.QueryComponentColumnNames(ctx, tx)
if err != nil { if err != nil {
return fmt.Errorf("query component table column names: %w", err) logger.Error(ctx, "query component table column names failed", "error", err)
panic(err)
} }
err = model.StoreComponentColumnRecommend(ctx, fullParentPath, isLocalParentPath, componentColumnNames) err = model.StoreComponentColumnRecommend(ctx, fullParentPath, isLocalParentPath, componentColumnNames)
if err != nil { if err != nil {
return fmt.Errorf("store component column recommend content: %w", err) logger.Error(ctx, "store component column recommend content failed", "error", err)
} panic(err)
parameterRecords, err := database.QueryParameterInitializationRecords(ctx, tx)
if err != nil {
return fmt.Errorf("load parameter data objects from postgres: %w", err)
}
err = model.InitializeParameterDataObjects(ctx, parameterRecords)
if err != nil {
return fmt.Errorf("initialize parameter data objects: %w", err)
}
measurementRecords, err := database.QueryMeasurementInitializationRecords(ctx, tx)
if err != nil {
return fmt.Errorf("load measurement data objects from postgres: %w", err)
}
err = model.InitializeMeasurementDataObjects(ctx, measurementRecords)
if err != nil {
return fmt.Errorf("initialize measurement data objects: %w", err)
} }
allMeasurement, err := database.GetAllMeasurements(ctx, tx) allMeasurement, err := database.GetAllMeasurements(ctx, tx)
if err != nil { if err != nil {
return fmt.Errorf("load measurements from postgres: %w", err) logger.Error(ctx, "load topologic info from postgres failed", "error", err)
panic(err)
} }
go realtimedata.StartComputingRealTimeDataLimit(ctx, allMeasurement) go realtimedata.StartComputingRealTimeDataLimit(ctx, allMeasurement)
topologics, err := database.QueryTopologic(ctx, tx) topologics, err := database.QueryTopologic(ctx, tx)
if err != nil { if err != nil {
return fmt.Errorf("load topologic info from postgres: %w", err) logger.Error(ctx, "load topologic info from postgres failed", "error", err)
panic(err)
} }
diagram.SetGlobalTopologyGraph(diagram.NewTopologyGraph(topologics)) diagram.SetGlobalTopologyGraph(diagram.NewTopologyGraph(topologics))
return nil return nil
}); err != nil { })
logger.Error(ctx, "initialize modelRT startup data failed", "error", err)
panic(err)
}
// use release mode in production // use release mode in production
if modelRTConfig.DeployEnv == constants.ProductionDeployMode { if modelRTConfig.DeployEnv == constants.ProductionDeployMode {

View File

@ -1,78 +0,0 @@
package model
import (
"context"
"errors"
"fmt"
"modelRT/common"
"modelRT/constants"
"github.com/redis/go-redis/v9"
)
// DataObjectRedisAliasKey returns the Redis string key used to resolve any
// supported token form to the one canonical full-token hash.
func DataObjectRedisAliasKey(dataObjectType constants.DataObjectType, token string) (string, error) {
switch dataObjectType {
case constants.DataObjectTypeParameter:
return constants.RedisParameterDataObjectAliasPrefix + token, nil
case constants.DataObjectTypeMeasurement:
return constants.RedisMeasurementDataObjectAliasPrefix + token, nil
default:
return "", fmt.Errorf("unsupported data object type %q", dataObjectType)
}
}
// ResolveDataObjectRedisKey resolves a full or short token to the canonical
// full-token Redis hash created during data-object initialization.
func ResolveDataObjectRedisKey(
ctx context.Context,
rdb *redis.Client,
dataObjectType constants.DataObjectType,
token string,
) (string, error) {
if rdb == nil {
return "", fmt.Errorf("redis client is not initialized")
}
classifiedType, err := ClassifyDataObjectToken(token)
if err != nil {
return "", err
}
if classifiedType != dataObjectType {
return "", fmt.Errorf("token %q is %q, expected %q", token, classifiedType, dataObjectType)
}
aliasKey, err := DataObjectRedisAliasKey(dataObjectType, token)
if err != nil {
return "", err
}
canonicalKey, err := rdb.Get(ctx, aliasKey).Result()
if errors.Is(err, redis.Nil) {
switch dataObjectType {
case constants.DataObjectTypeParameter:
return "", fmt.Errorf("%w: %q", common.ErrParameterTokenNotFound, token)
case constants.DataObjectTypeMeasurement:
return "", fmt.Errorf("%w: %q", common.ErrMeasurementTokenNotFound, token)
}
}
if err != nil {
return "", fmt.Errorf("resolve redis data-object alias %q: %w", token, err)
}
if canonicalKey == "" {
return "", fmt.Errorf("redis data-object alias %q points to an empty key", token)
}
keyType, err := rdb.Type(ctx, canonicalKey).Result()
if err != nil {
return "", fmt.Errorf("query canonical redis key type for %q: %w", token, err)
}
if keyType != "hash" {
return "", fmt.Errorf(
"redis data-object alias %q points to key %q with type %q, expected hash",
token,
canonicalKey,
keyType,
)
}
return canonicalKey, nil
}

View File

@ -1,25 +0,0 @@
package model
import (
"testing"
"modelRT/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDataObjectRedisAliasKey(t *testing.T) {
parameterKey, err := DataObjectRedisAliasKey(constants.DataObjectTypeParameter, "nspath.component.rated.voltage")
require.NoError(t, err)
assert.Equal(t, constants.RedisParameterDataObjectAliasPrefix+"nspath.component.rated.voltage", parameterKey)
measurementKey, err := DataObjectRedisAliasKey(constants.DataObjectTypeMeasurement, "nspath.current")
require.NoError(t, err)
assert.Equal(t, constants.RedisMeasurementDataObjectAliasPrefix+"nspath.current", measurementKey)
}
func TestDataObjectRedisAliasKeyRejectsUnsupportedType(t *testing.T) {
_, err := DataObjectRedisAliasKey(constants.DataObjectType("unknown"), "token")
require.Error(t, err)
}

View File

@ -9,6 +9,18 @@ import (
"modelRT/constants" "modelRT/constants"
) )
var parameterAttributeGroups = map[string]struct{}{
"component": {},
"base_extend": {},
"rated": {},
"setup": {},
"model": {},
"stable": {},
"craft": {},
"integrity": {},
"behavior": {},
}
// ClassifyDataObjectToken determines whether token identifies a parameter or a // ClassifyDataObjectToken determines whether token identifies a parameter or a
// measurement. Seven-part and four-part tokens are classified by token6, while // measurement. Seven-part and four-part tokens are classified by token6, while
// two-part tokens are treated as measurements at the current stage. // two-part tokens are treated as measurements at the current stage.
@ -28,7 +40,7 @@ func ClassifyDataObjectToken(token string) (constants.DataObjectType, error) {
} }
token6 := parts[token6Index] token6 := parts[token6Index]
if constants.IsSupportedParameterAttributeGroup(token6) { if _, ok := parameterAttributeGroups[token6]; ok {
return constants.DataObjectTypeParameter, nil return constants.DataObjectTypeParameter, nil
} }
if token6 == "bay" { if token6 == "bay" {

View File

@ -2,27 +2,50 @@ package model
import ( import (
"fmt" "fmt"
"strings"
"modelRT/constants" "modelRT/constants"
"modelRT/orm"
) )
// MeasurementTypeString converts measurement.type from PostgreSQL to the var allowedMeasurementTypes = map[string]struct{}{
// electric-element type stored in the Redis data-object hash. "TM": {},
func MeasurementTypeString(measurementType int16) (string, error) { "TS": {},
switch measurementType { "TC": {},
case constants.MeasurementTypeTelemetry: "TA": {},
return "TM", nil "SP": {},
case constants.MeasurementTypeTelesignal: }
return "TS", nil
case constants.MeasurementTypeTelecommand: // MeasurementTypeFromDataSource returns the two-character measurement type
return "TC", nil // encoded in a CL3611 channel. Only TM, TS, TC, TA, and SP are valid.
case constants.MeasurementTypeTeleadjusting: func MeasurementTypeFromDataSource(dataSource orm.JSONMap) (string, error) {
return "TA", nil dataSourceType, err := integerJSONValue(dataSource["type"])
case constants.MeasurementTypeSetpoint: if err != nil {
return "SP", nil return "", fmt.Errorf("invalid measurement data_source type: %w", err)
default:
return "", fmt.Errorf("unsupported measurement type %d", measurementType)
} }
if dataSourceType != constants.DataSourceTypeCL3611 {
return "", fmt.Errorf("measurement type requires data_source type %d, got %d", constants.DataSourceTypeCL3611, dataSourceType)
}
ioAddress, ok := dataSource["io_address"].(map[string]any)
if !ok {
if value, jsonMapOK := dataSource["io_address"].(orm.JSONMap); jsonMapOK {
ioAddress = map[string]any(value)
} else {
return "", fmt.Errorf("measurement data_source io_address is not an object")
}
}
channel, ok := ioAddress["channel"].(string)
if !ok || len(channel) < 2 {
return "", fmt.Errorf("measurement data_source channel must contain at least two characters")
}
measurementType := strings.ToUpper(channel[:2])
if _, ok := allowedMeasurementTypes[measurementType]; !ok {
return "", fmt.Errorf("unsupported measurement type %q", measurementType)
}
return measurementType, nil
} }
func integerJSONValue(value any) (int, error) { func integerJSONValue(value any) (int, error) {

View File

@ -1,37 +1,40 @@
package model package model
import ( import (
"strings"
"testing" "testing"
"modelRT/orm"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestMeasurementTypeString(t *testing.T) { func TestMeasurementTypeFromDataSource(t *testing.T) {
tests := []struct { for _, measurementType := range []string{"TM", "TS", "TC", "TA", "SP"} {
value int16 t.Run(measurementType, func(t *testing.T) {
expected string actual, err := MeasurementTypeFromDataSource(orm.JSONMap{
}{ "type": float64(1),
{value: 0, expected: "TM"}, "io_address": map[string]any{
{value: 1, expected: "TS"}, "channel": strings.ToLower(measurementType) + "1_test",
{value: 2, expected: "TC"}, },
{value: 3, expected: "TA"}, })
{value: 4, expected: "SP"},
}
for _, test := range tests {
t.Run(test.expected, func(t *testing.T) {
actual, err := MeasurementTypeString(test.value)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, test.expected, actual) assert.Equal(t, measurementType, actual)
}) })
} }
} }
func TestMeasurementTypeStringRejectsInvalidValues(t *testing.T) { func TestMeasurementTypeFromDataSourceRejectsInvalidValues(t *testing.T) {
for _, value := range []int16{-1, 5, 100} { tests := []orm.JSONMap{
_, err := MeasurementTypeString(value) {"type": float64(2), "io_address": map[string]any{"channel": "tm1"}},
{"type": float64(1), "io_address": map[string]any{"channel": "xx1"}},
{"type": float64(1), "io_address": map[string]any{"channel": "t"}},
{"type": "1", "io_address": map[string]any{"channel": "tm1"}},
}
for _, dataSource := range tests {
_, err := MeasurementTypeFromDataSource(dataSource)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported measurement type")
} }
} }

View File

@ -1,268 +0,0 @@
package model
import (
"context"
"encoding/json"
"fmt"
"strings"
"modelRT/constants"
"modelRT/diagram"
"modelRT/logger"
"modelRT/orm"
"github.com/redis/go-redis/v9"
)
const measurementDataObjectPipelineSize = 500
type measurementDataObjectHash struct {
Key string
Aliases []string
Fields map[string]any
}
// MeasurementInitializationRecord contains a measurement and the hierarchy
// needed to create all supported Redis data-object token aliases.
type MeasurementInitializationRecord struct {
GridTag string `gorm:"column:grid_tag"`
ZoneTag string `gorm:"column:zone_tag"`
StationTag string `gorm:"column:station_tag"`
ComponentUUID string `gorm:"column:component_uuid"`
ComponentNSPath string `gorm:"column:component_nspath"`
ComponentTag string `gorm:"column:component_tag"`
MeasurementID int64 `gorm:"column:measurement_id"`
MeasurementTag string `gorm:"column:measurement_tag"`
MeasurementName string `gorm:"column:measurement_name"`
MeasurementType int16 `gorm:"column:measurement_type"`
MeasurementMode int16 `gorm:"column:measurement_mode"`
MeasurementSize int `gorm:"column:measurement_size"`
MeasurementDataSource orm.JSONMap `gorm:"column:measurement_data_source;type:jsonb"`
MeasurementEventPlan orm.JSONMap `gorm:"column:measurement_event_plan;type:jsonb"`
MeasurementBinding orm.JSONMap `gorm:"column:measurement_binding;type:jsonb"`
}
// InitializeMeasurementDataObjects creates one seven-part Redis hash and
// aliases for all supported measurement token forms.
func InitializeMeasurementDataObjects(ctx context.Context, records []MeasurementInitializationRecord) error {
hashes, err := buildMeasurementDataObjectHashes(records)
if err != nil {
return fmt.Errorf("build measurement data-object hashes: %w", err)
}
if err := storeMeasurementDataObjectHashes(ctx, diagram.GetRedisClientInstance(), hashes); err != nil {
return fmt.Errorf("store measurement data-object hashes in redis: %w", err)
}
logger.Info(ctx, "initialize measurement data objects completed",
"postgres_record_count", len(records),
"redis_hash_count", len(hashes),
)
return nil
}
func buildMeasurementDataObjectHashes(records []MeasurementInitializationRecord) ([]measurementDataObjectHash, error) {
hashes := make([]measurementDataObjectHash, 0, len(records))
seenKeys := make(map[string]string, len(records)*3)
for _, record := range records {
if record.MeasurementMode != constants.MeasurementModeManual &&
record.MeasurementMode != constants.MeasurementModeAutomatic {
return nil, fmt.Errorf(
"measurement %q mode must be %d or %d, got %d",
record.MeasurementTag,
constants.MeasurementModeManual,
constants.MeasurementModeAutomatic,
record.MeasurementMode,
)
}
if record.MeasurementSize <= 0 {
return nil, fmt.Errorf(
"measurement %q window size must be greater than 0, got %d",
record.MeasurementTag,
record.MeasurementSize,
)
}
if record.MeasurementDataSource == nil ||
record.MeasurementEventPlan == nil ||
record.MeasurementBinding == nil {
return nil, fmt.Errorf("measurement %q contains a null JSONB field", record.MeasurementTag)
}
fullToken := strings.Join([]string{
record.GridTag,
record.ZoneTag,
record.StationTag,
record.ComponentNSPath,
record.ComponentTag,
"bay",
record.MeasurementTag,
}, ".")
fourPartToken := strings.Join([]string{
record.ComponentNSPath,
record.ComponentTag,
"bay",
record.MeasurementTag,
}, ".")
twoPartToken := record.ComponentNSPath + "." + record.MeasurementTag
aliases := []string{fullToken, fourPartToken, twoPartToken}
for _, token := range aliases {
if err := validateInitializedMeasurementToken(token); err != nil {
return nil, err
}
}
measurementType, err := MeasurementTypeString(record.MeasurementType)
if err != nil {
return nil, fmt.Errorf("derive type for measurement %q: %w", fullToken, err)
}
dataSource, err := measurementInitializationJSON(record.MeasurementDataSource)
if err != nil {
return nil, fmt.Errorf("encode data_source for measurement %q: %w", fullToken, err)
}
eventPlan, err := measurementInitializationJSON(record.MeasurementEventPlan)
if err != nil {
return nil, fmt.Errorf("encode event_plan for measurement %q: %w", fullToken, err)
}
binding, err := measurementInitializationJSON(record.MeasurementBinding)
if err != nil {
return nil, fmt.Errorf("encode binding for measurement %q: %w", fullToken, err)
}
fields := map[string]any{
"mode": record.MeasurementMode,
"meta": "MEASUREMENT",
"type": measurementType,
"name": twoPartToken,
"description": record.MeasurementName,
"id": fullToken,
"size": record.MeasurementSize,
"data_source": dataSource,
"event_plan": eventPlan,
"binding": binding,
}
owner := fmt.Sprintf("%d/%s", record.MeasurementID, record.ComponentUUID)
for _, alias := range aliases {
if existingOwner, exists := seenKeys[alias]; exists {
return nil, fmt.Errorf(
"ambiguous measurement token %q is produced by %q and %q",
alias,
existingOwner,
owner,
)
}
seenKeys[alias] = owner
}
hashes = append(hashes, measurementDataObjectHash{Key: fullToken, Aliases: aliases, Fields: fields})
}
return hashes, nil
}
func validateInitializedMeasurementToken(token string) error {
dataObjectType, err := ClassifyDataObjectToken(token)
if err != nil {
return fmt.Errorf("generated invalid measurement token %q: %w", token, err)
}
if dataObjectType != constants.DataObjectTypeMeasurement {
return fmt.Errorf("generated token %q is not a measurement", token)
}
return nil
}
func measurementInitializationJSON(value orm.JSONMap) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func storeMeasurementDataObjectHashes(
ctx context.Context,
rdb *redis.Client,
hashes []measurementDataObjectHash,
) error {
if rdb == nil {
return fmt.Errorf("redis client is nil")
}
oldKeys, err := rdb.SMembers(ctx, constants.RedisMeasurementDataObjectKeySet).Result()
if err != nil {
return fmt.Errorf("query previously initialized measurement keys: %w", err)
}
oldAliasKeys, err := rdb.SMembers(ctx, constants.RedisMeasurementDataObjectAliasKeySet).Result()
if err != nil {
return fmt.Errorf("query previously initialized measurement alias keys: %w", err)
}
currentKeys := make(map[string]struct{}, len(hashes))
currentAliasKeys := make(map[string]struct{}, len(hashes)*3)
for start := 0; start < len(hashes); start += measurementDataObjectPipelineSize {
end := min(start+measurementDataObjectPipelineSize, len(hashes))
pipeline := rdb.TxPipeline()
keyMembers := make([]any, 0, end-start)
aliasKeyMembers := make([]any, 0, (end-start)*3)
for _, hash := range hashes[start:end] {
pipeline.Del(ctx, hash.Key)
pipeline.HSet(ctx, hash.Key, hash.Fields)
keyMembers = append(keyMembers, hash.Key)
currentKeys[hash.Key] = struct{}{}
for _, alias := range hash.Aliases {
aliasKey, err := DataObjectRedisAliasKey(constants.DataObjectTypeMeasurement, alias)
if err != nil {
return err
}
pipeline.Set(ctx, aliasKey, hash.Key, 0)
aliasKeyMembers = append(aliasKeyMembers, aliasKey)
currentAliasKeys[aliasKey] = struct{}{}
}
}
if len(keyMembers) > 0 {
pipeline.SAdd(ctx, constants.RedisMeasurementDataObjectKeySet, keyMembers...)
}
if len(aliasKeyMembers) > 0 {
pipeline.SAdd(ctx, constants.RedisMeasurementDataObjectAliasKeySet, aliasKeyMembers...)
}
if _, err := pipeline.Exec(ctx); err != nil {
return fmt.Errorf("write measurement data-object hash batch starting at %d: %w", start, err)
}
}
staleKeys := make([]string, 0)
for _, key := range oldKeys {
if _, exists := currentKeys[key]; !exists {
staleKeys = append(staleKeys, key)
}
}
cleanupPipeline := rdb.TxPipeline()
for start := 0; start < len(staleKeys); start += measurementDataObjectPipelineSize {
end := min(start+measurementDataObjectPipelineSize, len(staleKeys))
cleanupPipeline.Del(ctx, staleKeys[start:end]...)
members := make([]any, 0, end-start)
for _, key := range staleKeys[start:end] {
members = append(members, key)
}
cleanupPipeline.SRem(ctx, constants.RedisMeasurementDataObjectKeySet, members...)
}
staleAliasKeys := make([]string, 0)
for _, aliasKey := range oldAliasKeys {
if _, exists := currentAliasKeys[aliasKey]; !exists {
staleAliasKeys = append(staleAliasKeys, aliasKey)
}
}
for start := 0; start < len(staleAliasKeys); start += measurementDataObjectPipelineSize {
end := min(start+measurementDataObjectPipelineSize, len(staleAliasKeys))
cleanupPipeline.Del(ctx, staleAliasKeys[start:end]...)
members := make([]any, 0, end-start)
for _, aliasKey := range staleAliasKeys[start:end] {
members = append(members, aliasKey)
}
cleanupPipeline.SRem(ctx, constants.RedisMeasurementDataObjectAliasKeySet, members...)
}
if len(hashes) == 0 {
cleanupPipeline.Del(ctx, constants.RedisMeasurementDataObjectKeySet)
cleanupPipeline.Del(ctx, constants.RedisMeasurementDataObjectAliasKeySet)
}
if _, err := cleanupPipeline.Exec(ctx); err != nil {
return fmt.Errorf("remove stale measurement data-object hashes: %w", err)
}
return nil
}

View File

@ -1,107 +0,0 @@
package model
import (
"testing"
"modelRT/orm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildMeasurementDataObjectHashesCreatesCanonicalHashAndAllAliases(t *testing.T) {
record := measurementInitializationRecordForTest()
hashes, err := buildMeasurementDataObjectHashes([]MeasurementInitializationRecord{record})
require.NoError(t, err)
require.Len(t, hashes, 1)
fullToken := "grid000.zone000.station000.220kV_xuefulu1.CTA.bay.IA_rms"
fourPartToken := "220kV_xuefulu1.CTA.bay.IA_rms"
twoPartToken := "220kV_xuefulu1.IA_rms"
assert.Equal(t, fullToken, hashes[0].Key)
assert.Equal(t, []string{fullToken, fourPartToken, twoPartToken}, hashes[0].Aliases)
fields := hashes[0].Fields
assert.NotContains(t, fields, "value")
assert.Equal(t, int16(1), fields["mode"])
assert.Equal(t, "MEASUREMENT", fields["meta"])
assert.Equal(t, "TM", fields["type"])
assert.Equal(t, twoPartToken, fields["name"])
assert.Equal(t, "A相保护电流有效值", fields["description"])
assert.Equal(t, fullToken, fields["id"])
assert.Equal(t, 1, fields["size"])
assert.Equal(t, `{"io_address":{"channel":"TM1","device":"CTA","dtype":1,"option":"rms","station":"001"},"type":1}`, fields["data_source"])
assert.Equal(t, `{}`, fields["event_plan"])
assert.Equal(t, `{"ct":{"index":0,"polarity":1,"ratio":1250}}`, fields["binding"])
}
func TestBuildMeasurementDataObjectHashesRejectsAmbiguousShortToken(t *testing.T) {
first := measurementInitializationRecordForTest()
second := first
second.GridTag = "grid001"
second.ZoneTag = "zone001"
second.StationTag = "station001"
second.ComponentUUID = "component-uuid-2"
second.ComponentTag = "CTB"
second.MeasurementID = 2
_, err := buildMeasurementDataObjectHashes([]MeasurementInitializationRecord{first, second})
require.Error(t, err)
assert.Contains(t, err.Error(), "ambiguous measurement token")
assert.Contains(t, err.Error(), "220kV_xuefulu1.IA_rms")
}
func TestBuildMeasurementDataObjectHashesRejectsUnsupportedMeasurementType(t *testing.T) {
record := measurementInitializationRecordForTest()
record.MeasurementType = 5
_, err := buildMeasurementDataObjectHashes([]MeasurementInitializationRecord{record})
require.Error(t, err)
assert.Contains(t, err.Error(), "derive type")
assert.Contains(t, err.Error(), "unsupported measurement type 5")
}
func TestBuildMeasurementDataObjectHashesRejectsInvalidWindowSize(t *testing.T) {
record := measurementInitializationRecordForTest()
record.MeasurementSize = 0
_, err := buildMeasurementDataObjectHashes([]MeasurementInitializationRecord{record})
require.Error(t, err)
assert.Contains(t, err.Error(), "window size must be greater than 0")
}
func measurementInitializationRecordForTest() MeasurementInitializationRecord {
return MeasurementInitializationRecord{
GridTag: "grid000",
ZoneTag: "zone000",
StationTag: "station000",
ComponentUUID: "component-uuid-1",
ComponentNSPath: "220kV_xuefulu1",
ComponentTag: "CTA",
MeasurementID: 1,
MeasurementTag: "IA_rms",
MeasurementName: "A相保护电流有效值",
MeasurementType: 0,
MeasurementMode: 1,
MeasurementSize: 1,
MeasurementDataSource: orm.JSONMap{
"type": float64(1),
"io_address": map[string]any{
"station": "001",
"device": "CTA",
"channel": "TM1",
"dtype": float64(1),
"option": "rms",
},
},
MeasurementEventPlan: orm.JSONMap{},
MeasurementBinding: orm.JSONMap{
"ct": map[string]any{
"index": float64(0),
"ratio": float64(1250),
"polarity": float64(1),
},
},
}
}

View File

@ -11,14 +11,6 @@ import (
"modelRT/constants" "modelRT/constants"
) )
const (
// CL3611DataSourceTypePhasor define identifies CL3611 phasor data source.
CL3611DataSourceTypePhasor = 1
// CL3611DataSourceTypeSample define identifies CL3611 sampled data source.
CL3611DataSourceTypeSample = 2
)
// MeasurementDataSource define measurement data source struct // MeasurementDataSource define measurement data source struct
type MeasurementDataSource struct { type MeasurementDataSource struct {
Type int `json:"type"` Type int `json:"type"`
@ -230,11 +222,6 @@ func GenerateMeasureIdentifier(source map[string]any) (string, error) {
switch regType { switch regType {
case constants.DataSourceTypeCL3611: case constants.DataSourceTypeCL3611:
rawDtype, ok := ioAddress["dtype"].(float64)
if !ok {
return "", fmt.Errorf("CL3611:invalid or missing dtype field")
}
station, ok := ioAddress["station"].(string) station, ok := ioAddress["station"].(string)
if !ok { if !ok {
return "", fmt.Errorf("CL3611:invalid or missing station field") return "", fmt.Errorf("CL3611:invalid or missing station field")
@ -248,21 +235,7 @@ func GenerateMeasureIdentifier(source map[string]any) (string, error) {
if !ok { if !ok {
return "", fmt.Errorf("CL3611:invalid or missing channel field") return "", fmt.Errorf("CL3611:invalid or missing channel field")
} }
return concatCL361WithPlus(station, device, channel), nil
optinon, ok := ioAddress["option"].(string)
if !ok {
return "", fmt.Errorf("CL3611:invalid or missing optinon field")
}
dtype := int(rawDtype)
switch dtype {
case CL3611DataSourceTypePhasor:
return buildCL3611PhasorIdentifier(station, device, channel, optinon), nil
case CL3611DataSourceTypeSample:
return buildCL3611SampleIdentifier(station, device, channel), nil
default:
return "", fmt.Errorf("CL3611:unsupported dtype %d", dtype)
}
case constants.DataSourceTypePower104: case constants.DataSourceTypePower104:
station, ok := ioAddress["station"].(string) station, ok := ioAddress["station"].(string)
if !ok { if !ok {
@ -297,10 +270,6 @@ func concatP104WithPlus(station string, packet int, offset int) string {
return strings.ToLower(station + ":104:" + packetStr + ":" + offsetStr) return strings.ToLower(station + ":104:" + packetStr + ":" + offsetStr)
} }
func buildCL3611SampleIdentifier(station, device, channel string) string { func concatCL361WithPlus(station, device, channel string) string {
return strings.ToLower(station + ":" + device + ":" + "phasor" + ":" + channel) return strings.ToLower(station + ":" + device + ":" + "phasor" + ":" + channel)
} }
func buildCL3611PhasorIdentifier(station, device, channel, option string) string {
return strings.ToLower(station + ":" + device + ":" + "phasor" + ":" + channel + ":" + option)
}

View File

@ -1,263 +0,0 @@
package model
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"modelRT/constants"
"modelRT/diagram"
"modelRT/logger"
"github.com/redis/go-redis/v9"
)
const parameterDataObjectPipelineSize = 500
type parameterDataObjectHash struct {
Key string
Aliases []string
Fields map[string]any
}
// ParameterInitializationRecord contains one parameter attribute together with
// the hierarchy and metadata needed to create its Redis data-object hashes.
type ParameterInitializationRecord struct {
GridTag string `gorm:"column:grid_tag"`
ZoneTag string `gorm:"column:zone_tag"`
StationTag string `gorm:"column:station_tag"`
StationIsLocal bool `gorm:"column:station_is_local"`
ComponentUUID string `gorm:"column:component_uuid"`
ComponentNSPath string `gorm:"column:component_nspath"`
ComponentTag string `gorm:"column:component_tag"`
AttributeGroup string `gorm:"column:attribute_group"`
AttributeName string `gorm:"column:attribute_name"`
AttributeValue string `gorm:"column:attribute_value"`
AttributeType string `gorm:"column:attribute_type"`
Description sql.NullString `gorm:"column:description"`
DescriptionCount int64 `gorm:"column:description_count"`
DynamicRecordCount int64 `gorm:"column:dynamic_record_count"`
}
// InitializeParameterDataObjects creates one full-token Redis hash and full or
// local-short token aliases for each parameter loaded from PostgreSQL.
func InitializeParameterDataObjects(ctx context.Context, records []ParameterInitializationRecord) error {
hashes, err := buildParameterDataObjectHashes(records)
if err != nil {
return fmt.Errorf("build parameter data-object hashes: %w", err)
}
if err := storeParameterDataObjectHashes(ctx, diagram.GetRedisClientInstance(), hashes); err != nil {
return fmt.Errorf("store parameter data-object hashes in redis: %w", err)
}
logger.Info(ctx, "initialize parameter data objects completed",
"postgres_record_count", len(records),
"redis_hash_count", len(hashes),
)
return nil
}
func buildParameterDataObjectHashes(records []ParameterInitializationRecord) ([]parameterDataObjectHash, error) {
hashes := make([]parameterDataObjectHash, 0, len(records))
seenKeys := make(map[string]string, len(records)*2)
for _, record := range records {
value, err := parameterRedisValue(record.AttributeValue)
if err != nil {
return nil, fmt.Errorf(
"decode value for component %q group %q attribute %q: %w",
record.ComponentTag,
record.AttributeGroup,
record.AttributeName,
err,
)
}
fullToken := strings.Join([]string{
record.GridTag,
record.ZoneTag,
record.StationTag,
record.ComponentNSPath,
record.ComponentTag,
record.AttributeGroup,
record.AttributeName,
}, ".")
shortToken := strings.Join([]string{
record.ComponentNSPath,
record.ComponentTag,
record.AttributeGroup,
record.AttributeName,
}, ".")
aliases := []string{fullToken}
if record.StationIsLocal {
aliases = append(aliases, shortToken)
}
fields := map[string]any{
"value": value,
"meta": "PARAM",
"type": record.AttributeType,
"name": shortToken,
"description": record.Description.String,
"id": fullToken,
}
owner := strings.Join([]string{
record.ComponentUUID,
record.AttributeGroup,
record.AttributeName,
}, "/")
for _, alias := range aliases {
if err := validateInitializedParameterToken(alias); err != nil {
return nil, err
}
if existingOwner, exists := seenKeys[alias]; exists {
return nil, fmt.Errorf(
"ambiguous parameter token %q is produced by %q and %q",
alias,
existingOwner,
owner,
)
}
seenKeys[alias] = owner
}
hashes = append(hashes, parameterDataObjectHash{Key: fullToken, Aliases: aliases, Fields: fields})
}
return hashes, nil
}
func validateInitializedParameterToken(token string) error {
dataObjectType, err := ClassifyDataObjectToken(token)
if err != nil {
return fmt.Errorf("generated invalid parameter token %q: %w", token, err)
}
if dataObjectType != constants.DataObjectTypeParameter {
return fmt.Errorf("generated token %q is not a parameter", token)
}
return nil
}
func parameterRedisValue(rawJSON string) (any, error) {
decoder := json.NewDecoder(bytes.NewBufferString(rawJSON))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
switch typedValue := value.(type) {
case nil:
return "null", nil
case string:
return typedValue, nil
case json.Number:
return typedValue.String(), nil
case bool:
return typedValue, nil
default:
encoded, err := json.Marshal(typedValue)
if err != nil {
return nil, err
}
return string(encoded), nil
}
}
func storeParameterDataObjectHashes(
ctx context.Context,
rdb *redis.Client,
hashes []parameterDataObjectHash,
) error {
if rdb == nil {
return fmt.Errorf("redis client is nil")
}
oldKeys, err := rdb.SMembers(ctx, constants.RedisParameterDataObjectKeySet).Result()
if err != nil {
return fmt.Errorf("query previously initialized parameter keys: %w", err)
}
oldAliasKeys, err := rdb.SMembers(ctx, constants.RedisParameterDataObjectAliasKeySet).Result()
if err != nil {
return fmt.Errorf("query previously initialized parameter alias keys: %w", err)
}
currentKeys := make(map[string]struct{}, len(hashes))
currentAliasKeys := make(map[string]struct{}, len(hashes)*2)
for start := 0; start < len(hashes); start += parameterDataObjectPipelineSize {
end := min(start+parameterDataObjectPipelineSize, len(hashes))
pipeline := rdb.TxPipeline()
keyMembers := make([]any, 0, end-start)
aliasKeyMembers := make([]any, 0, (end-start)*2)
for _, hash := range hashes[start:end] {
pipeline.Del(ctx, hash.Key)
pipeline.HSet(ctx, hash.Key, hash.Fields)
keyMembers = append(keyMembers, hash.Key)
currentKeys[hash.Key] = struct{}{}
for _, alias := range hash.Aliases {
aliasKey, err := DataObjectRedisAliasKey(constants.DataObjectTypeParameter, alias)
if err != nil {
return err
}
pipeline.Set(ctx, aliasKey, hash.Key, 0)
aliasKeyMembers = append(aliasKeyMembers, aliasKey)
currentAliasKeys[aliasKey] = struct{}{}
}
}
if len(keyMembers) > 0 {
pipeline.SAdd(ctx, constants.RedisParameterDataObjectKeySet, keyMembers...)
}
if len(aliasKeyMembers) > 0 {
pipeline.SAdd(ctx, constants.RedisParameterDataObjectAliasKeySet, aliasKeyMembers...)
}
if _, err := pipeline.Exec(ctx); err != nil {
return fmt.Errorf("write parameter data-object hash batch starting at %d: %w", start, err)
}
}
if err := cleanupStaleParameterDataObjectKeys(
ctx,
rdb,
oldKeys,
oldAliasKeys,
currentKeys,
currentAliasKeys,
); err != nil {
return err
}
return nil
}
func cleanupStaleParameterDataObjectKeys(
ctx context.Context,
rdb *redis.Client,
oldKeys, oldAliasKeys []string,
currentKeys, currentAliasKeys map[string]struct{},
) error {
pipeline := rdb.TxPipeline()
for _, key := range oldKeys {
if _, exists := currentKeys[key]; exists {
continue
}
pipeline.Del(ctx, key)
pipeline.SRem(ctx, constants.RedisParameterDataObjectKeySet, key)
}
for _, aliasKey := range oldAliasKeys {
if _, exists := currentAliasKeys[aliasKey]; exists {
continue
}
pipeline.Del(ctx, aliasKey)
pipeline.SRem(ctx, constants.RedisParameterDataObjectAliasKeySet, aliasKey)
}
if len(currentKeys) == 0 {
pipeline.Del(ctx, constants.RedisParameterDataObjectKeySet)
}
if len(currentAliasKeys) == 0 {
pipeline.Del(ctx, constants.RedisParameterDataObjectAliasKeySet)
}
if _, err := pipeline.Exec(ctx); err != nil {
return fmt.Errorf("remove stale parameter data-object keys: %w", err)
}
return nil
}

View File

@ -1,124 +0,0 @@
package model
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildParameterDataObjectHashesCreatesCanonicalHashAndLocalAliases(t *testing.T) {
records := []ParameterInitializationRecord{
{
GridTag: "grid000",
ZoneTag: "zone000",
StationTag: "station000",
StationIsLocal: true,
ComponentUUID: "component-uuid",
ComponentNSPath: "220kV_xuefulu1",
ComponentTag: "cable_22",
AttributeGroup: "base_extend",
AttributeName: "vnom_kv",
AttributeValue: "7800.00",
AttributeType: "DOUBLE PRECISION",
Description: sql.NullString{String: "额定电压", Valid: true},
DescriptionCount: 1,
DynamicRecordCount: 1,
},
}
hashes, err := buildParameterDataObjectHashes(records)
require.NoError(t, err)
require.Len(t, hashes, 1)
fullToken := "grid000.zone000.station000.220kV_xuefulu1.cable_22.base_extend.vnom_kv"
shortToken := "220kV_xuefulu1.cable_22.base_extend.vnom_kv"
assert.Equal(t, fullToken, hashes[0].Key)
assert.Equal(t, []string{fullToken, shortToken}, hashes[0].Aliases)
assert.Equal(t, "7800.00", hashes[0].Fields["value"])
assert.Equal(t, "PARAM", hashes[0].Fields["meta"])
assert.Equal(t, "DOUBLE PRECISION", hashes[0].Fields["type"])
assert.Equal(t, shortToken, hashes[0].Fields["name"])
assert.Equal(t, "额定电压", hashes[0].Fields["description"])
assert.Equal(t, fullToken, hashes[0].Fields["id"])
}
func TestBuildParameterDataObjectHashesSkipsShortKeyForNonLocalStation(t *testing.T) {
records := []ParameterInitializationRecord{
{
GridTag: "grid",
ZoneTag: "zone",
StationTag: "station",
StationIsLocal: false,
ComponentUUID: "component-uuid",
ComponentNSPath: "nspath",
ComponentTag: "component",
AttributeGroup: "stable",
AttributeName: "attribute",
AttributeValue: "true",
AttributeType: "BOOLEAN",
Description: sql.NullString{String: "属性", Valid: true},
DescriptionCount: 1,
DynamicRecordCount: 1,
},
}
hashes, err := buildParameterDataObjectHashes(records)
require.NoError(t, err)
require.Len(t, hashes, 1)
assert.Equal(t, "grid.zone.station.nspath.component.stable.attribute", hashes[0].Key)
assert.Equal(t, []string{"grid.zone.station.nspath.component.stable.attribute"}, hashes[0].Aliases)
assert.Equal(t, true, hashes[0].Fields["value"])
}
func TestBuildParameterDataObjectHashesRejectsAmbiguousShortToken(t *testing.T) {
baseRecord := ParameterInitializationRecord{
GridTag: "grid1",
ZoneTag: "zone1",
StationTag: "station1",
StationIsLocal: true,
ComponentUUID: "component-uuid-1",
ComponentNSPath: "nspath",
ComponentTag: "component",
AttributeGroup: "stable",
AttributeName: "attribute",
AttributeValue: "1",
AttributeType: "INTEGER",
Description: sql.NullString{String: "属性", Valid: true},
DescriptionCount: 1,
DynamicRecordCount: 1,
}
otherRecord := baseRecord
otherRecord.GridTag = "grid2"
otherRecord.ZoneTag = "zone2"
otherRecord.StationTag = "station2"
otherRecord.ComponentUUID = "component-uuid-2"
_, err := buildParameterDataObjectHashes([]ParameterInitializationRecord{baseRecord, otherRecord})
require.Error(t, err)
assert.Contains(t, err.Error(), "ambiguous parameter token")
assert.Contains(t, err.Error(), "nspath.component.stable.attribute")
}
func TestParameterRedisValuePreservesHashRepresentations(t *testing.T) {
tests := []struct {
name string
rawValue string
expected any
}{
{name: "null", rawValue: "null", expected: "null"},
{name: "string", rawValue: `"text"`, expected: "text"},
{name: "number precision", rawValue: "1234567890.123456789", expected: "1234567890.123456789"},
{name: "boolean", rawValue: "true", expected: true},
{name: "object", rawValue: `{"key":"value"}`, expected: `{"key":"value"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := parameterRedisValue(test.rawValue)
require.NoError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}

View File

@ -1,343 +0,0 @@
// Package redis provides Redis persistence helpers.
package redis
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"time"
redisclient "github.com/redis/go-redis/v9"
)
const redisChangeRestoreTimeout = 5 * time.Second
type redisHashChange struct {
key string
field string
oldValue string
newValue string
}
type redisZSetChange struct {
key string
oldValues []redisclient.Z
newValues []redisclient.Z
}
// RedisChangeSet keeps the Redis changes belonging to one PostgreSQL
// transaction. Changes are prepared first and applied together immediately
// before the PostgreSQL transaction is committed.
type RedisChangeSet struct {
client *redisclient.Client
hashChanges []redisHashChange
zsetChanges []redisZSetChange
applied bool
}
func NewRedisChangeSet(client *redisclient.Client) *RedisChangeSet {
return &RedisChangeSet{client: client}
}
func (changes *RedisChangeSet) AddHashChange(
ctx context.Context,
canonicalKey, field string,
value any,
) error {
if changes == nil || changes.client == nil {
return fmt.Errorf("redis client is not initialized")
}
if canonicalKey == "" {
return fmt.Errorf("canonical redis key is empty")
}
newValue, err := redisChangeString(value)
if err != nil {
return fmt.Errorf("encode redis data-object value: %w", err)
}
oldValue, err := changes.client.HGet(ctx, canonicalKey, field).Result()
if err != nil {
return fmt.Errorf("query canonical redis hash %q field %q: %w", canonicalKey, field, err)
}
changes.hashChanges = append(changes.hashChanges, redisHashChange{
key: canonicalKey,
field: field,
oldValue: oldValue,
newValue: newValue,
})
return nil
}
func (changes *RedisChangeSet) AddMeasurementValueChange(
ctx context.Context,
key string,
value float64,
timestamp time.Time,
replace bool,
) error {
if changes == nil || changes.client == nil {
return fmt.Errorf("redis client is not initialized")
}
if key == "" {
return fmt.Errorf("measurement redis key is empty")
}
keyType, err := changes.client.Type(ctx, key).Result()
if err != nil {
return fmt.Errorf("query measurement redis key type for %q: %w", key, err)
}
if keyType != "none" && keyType != "zset" {
return fmt.Errorf("measurement redis key %q has type %q, expected zset", key, keyType)
}
oldValues, err := changes.client.ZRangeWithScores(ctx, key, 0, -1).Result()
if err != nil {
return fmt.Errorf("query measurement redis values for %q: %w", key, err)
}
newMember := strconv.FormatInt(timestamp.UnixNano(), 10)
newValues := []redisclient.Z{{Score: value, Member: newMember}}
if !replace {
newValues = mergeRedisZValues(oldValues, newValues...)
}
changes.zsetChanges = append(changes.zsetChanges, redisZSetChange{
key: key,
oldValues: normalizeRedisZValues(oldValues),
newValues: normalizeRedisZValues(newValues),
})
return nil
}
func (changes *RedisChangeSet) Apply(ctx context.Context) error {
if changes == nil || changes.client == nil {
return fmt.Errorf("redis client is not initialized")
}
if len(changes.hashChanges) == 0 && len(changes.zsetChanges) == 0 {
return nil
}
keys := changes.keys()
err := changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
if err := changes.verify(ctx, tx, false); err != nil {
return err
}
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
for _, change := range changes.hashChanges {
pipe.HSet(ctx, change.key, change.field, change.newValue)
}
for _, change := range changes.zsetChanges {
pipe.Del(ctx, change.key)
if len(change.newValues) > 0 {
pipe.ZAdd(ctx, change.key, change.newValues...)
}
}
return nil
})
return err
}, keys...)
if err != nil {
// A connection error can leave EXEC's outcome unknown. Restore only
// when Redis still contains either the prepared or the applied state.
restoreCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), redisChangeRestoreTimeout)
defer cancel()
if restoreErr := changes.restoreAfterApplyFailure(restoreCtx); restoreErr != nil {
return fmt.Errorf("apply redis changes: %w; restore redis changes: %v", err, restoreErr)
}
return fmt.Errorf("apply redis changes: %w", err)
}
changes.applied = true
return nil
}
// Revert restores Redis after a PostgreSQL commit failure. It uses WATCH and
// only restores values that still match this change set.
func (changes *RedisChangeSet) Revert(ctx context.Context) error {
if changes == nil || !changes.applied {
return nil
}
return changes.restoreOldValues(ctx, true)
}
func (changes *RedisChangeSet) restoreOldValues(ctx context.Context, compareNew bool) error {
keys := changes.keys()
return changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
if compareNew {
if err := changes.verify(ctx, tx, true); err != nil {
return err
}
}
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
for _, change := range changes.hashChanges {
pipe.HSet(ctx, change.key, change.field, change.oldValue)
}
for _, change := range changes.zsetChanges {
pipe.Del(ctx, change.key)
if len(change.oldValues) > 0 {
pipe.ZAdd(ctx, change.key, change.oldValues...)
}
}
return nil
})
return err
}, keys...)
}
func (changes *RedisChangeSet) restoreAfterApplyFailure(ctx context.Context) error {
keys := changes.keys()
return changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
for _, change := range changes.hashChanges {
actual, err := tx.HGet(ctx, change.key, change.field).Result()
if err != nil {
return fmt.Errorf("verify redis hash %q field %q after apply failure: %w", change.key, change.field, err)
}
if actual != change.oldValue && actual != change.newValue {
return fmt.Errorf("redis hash %q field %q changed concurrently", change.key, change.field)
}
}
for _, change := range changes.zsetChanges {
actual, err := tx.ZRangeWithScores(ctx, change.key, 0, -1).Result()
if err != nil {
return fmt.Errorf("verify redis zset %q after apply failure: %w", change.key, err)
}
if !equalRedisZValues(actual, change.oldValues) && !equalRedisZValues(actual, change.newValues) {
return fmt.Errorf("redis zset %q changed concurrently", change.key)
}
}
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
for _, change := range changes.hashChanges {
pipe.HSet(ctx, change.key, change.field, change.oldValue)
}
for _, change := range changes.zsetChanges {
pipe.Del(ctx, change.key)
if len(change.oldValues) > 0 {
pipe.ZAdd(ctx, change.key, change.oldValues...)
}
}
return nil
})
return err
}, keys...)
}
func (changes *RedisChangeSet) verify(ctx context.Context, tx *redisclient.Tx, expectNew bool) error {
for _, change := range changes.hashChanges {
expected := change.oldValue
if expectNew {
expected = change.newValue
}
actual, err := tx.HGet(ctx, change.key, change.field).Result()
if err != nil {
return fmt.Errorf("verify redis hash %q field %q: %w", change.key, change.field, err)
}
if actual != expected {
return fmt.Errorf("redis hash %q field %q changed concurrently", change.key, change.field)
}
}
for _, change := range changes.zsetChanges {
expected := change.oldValues
if expectNew {
expected = change.newValues
}
actual, err := tx.ZRangeWithScores(ctx, change.key, 0, -1).Result()
if err != nil {
return fmt.Errorf("verify redis zset %q: %w", change.key, err)
}
if !equalRedisZValues(actual, expected) {
return fmt.Errorf("redis zset %q changed concurrently", change.key)
}
}
return nil
}
func (changes *RedisChangeSet) keys() []string {
seen := make(map[string]struct{}, len(changes.hashChanges)+len(changes.zsetChanges))
keys := make([]string, 0, len(seen))
for _, change := range changes.hashChanges {
if _, ok := seen[change.key]; !ok {
seen[change.key] = struct{}{}
keys = append(keys, change.key)
}
}
for _, change := range changes.zsetChanges {
if _, ok := seen[change.key]; !ok {
seen[change.key] = struct{}{}
keys = append(keys, change.key)
}
}
sort.Strings(keys)
return keys
}
func redisChangeString(value any) (string, error) {
switch typedValue := value.(type) {
case string:
return typedValue, nil
case []byte:
return string(typedValue), nil
case nil:
return "null", nil
case bool:
return strconv.FormatBool(typedValue), nil
case int:
return strconv.Itoa(typedValue), nil
case int16:
return strconv.FormatInt(int64(typedValue), 10), nil
case int64:
return strconv.FormatInt(typedValue, 10), nil
case float64:
return strconv.FormatFloat(typedValue, 'f', -1, 64), nil
default:
encoded, err := json.Marshal(typedValue)
if err != nil {
return "", err
}
return string(encoded), nil
}
}
func cloneRedisZValues(values []redisclient.Z) []redisclient.Z {
cloned := make([]redisclient.Z, len(values))
copy(cloned, values)
return cloned
}
func mergeRedisZValues(current []redisclient.Z, additions ...redisclient.Z) []redisclient.Z {
valuesByMember := make(map[string]redisclient.Z, len(current)+len(additions))
for _, value := range current {
valuesByMember[fmt.Sprint(value.Member)] = value
}
for _, value := range additions {
valuesByMember[fmt.Sprint(value.Member)] = value
}
values := make([]redisclient.Z, 0, len(valuesByMember))
for _, value := range valuesByMember {
values = append(values, value)
}
return normalizeRedisZValues(values)
}
func normalizeRedisZValues(values []redisclient.Z) []redisclient.Z {
normalized := cloneRedisZValues(values)
sort.Slice(normalized, func(i, j int) bool {
if normalized[i].Score != normalized[j].Score {
return normalized[i].Score < normalized[j].Score
}
return fmt.Sprint(normalized[i].Member) < fmt.Sprint(normalized[j].Member)
})
return normalized
}
func equalRedisZValues(left, right []redisclient.Z) bool {
left = normalizeRedisZValues(left)
right = normalizeRedisZValues(right)
if len(left) != len(right) {
return false
}
for index := range left {
if left[index].Score != right[index].Score ||
fmt.Sprint(left[index].Member) != fmt.Sprint(right[index].Member) {
return false
}
}
return true
}

View File

@ -1,44 +0,0 @@
package redis
import (
"testing"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedisChangeStringUsesCacheRepresentations(t *testing.T) {
tests := []struct {
name string
value any
want string
}{
{name: "string", value: "15.2", want: "15.2"},
{name: "integer", value: int64(15), want: "15"},
{name: "decimal", value: 15.2, want: "15.2"},
{name: "boolean", value: true, want: "true"},
{name: "object", value: map[string]any{"enabled": true}, want: `{"enabled":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := redisChangeString(test.value)
require.NoError(t, err)
assert.Equal(t, test.want, got)
})
}
}
func TestMergeRedisZValuesReplacesDuplicateMemberAndSorts(t *testing.T) {
values := mergeRedisZValues(
[]redis.Z{
{Score: 20, Member: "2"},
{Score: 10, Member: "1"},
},
redis.Z{Score: 30, Member: "1"},
)
assert.Equal(t, []redis.Z{
{Score: 20, Member: "2"},
{Score: 30, Member: "1"},
}, values)
}

View File

@ -1,4 +1,4 @@
// Package sql defines reusable database SQL statements // Package sql defines reusable database SQL statements.
package sql package sql
const ( const (

View File

@ -1,38 +0,0 @@
// Package sql defines reusable database SQL statements
package sql
const (
// MeasurementInitializationRows joins measurements to the hierarchy used by
// all supported data-object token forms. The bay join guarantees that
// token6=bay refers to an existing bay record.
MeasurementInitializationRows = `SELECT
grid.tagname AS grid_tag,
zone.tagname AS zone_tag,
station.tagname AS station_tag,
component.global_uuid::text AS component_uuid,
component.nspath AS component_nspath,
component.tag AS component_tag,
measurement.id AS measurement_id,
measurement.tag AS measurement_tag,
measurement.name AS measurement_name,
measurement.type AS measurement_type,
measurement.mode AS measurement_mode,
measurement.size AS measurement_size,
measurement.data_source AS measurement_data_source,
measurement.event_plan AS measurement_event_plan,
measurement.binding AS measurement_binding
FROM public.grid AS grid
INNER JOIN public.zone AS zone ON zone.grid_id = grid.id
INNER JOIN public.station AS station ON station.zone_id = zone.id
INNER JOIN public.component AS component ON component.station_id = station.id
INNER JOIN public.measurement AS measurement
ON measurement.component_uuid = component.global_uuid
INNER JOIN public.bay AS bay
ON bay.bay_uuid = measurement.bay_uuid
WHERE grid.tagname <> ''
AND zone.tagname <> ''
AND station.tagname <> ''
AND component.nspath <> ''
AND component.tag <> ''
AND measurement.tag <> ''`
)

View File

@ -1,4 +1,4 @@
// Package sql defines reusable database SQL statements // Package sql defines reusable database SQL statements.
package sql package sql
const ( const (

View File

@ -1,124 +0,0 @@
// Package sql defines reusable database SQL statements
package sql
const (
// ParameterInitializationRoutes returns the dynamic-table mappings used by
// supported parameter attribute groups.
ParameterInitializationRoutes = `SELECT name, tag, group_name
FROM project_manager
WHERE group_name IN ?`
// DynamicParameterInitializationRows joins a dynamic parameter table to its
// component hierarchy and project_manager route. The table identifier is
// inserted only after application-level identifier and allowlist checks.
DynamicParameterInitializationRows = `WITH dynamic_rows AS (
SELECT dynamic_record.*,
COUNT(*) OVER (
PARTITION BY dynamic_record.global_uuid, dynamic_record.attribute_group
) AS initialization_record_count
FROM public.%[1]s AS dynamic_record
)
SELECT
grid.tagname AS grid_tag,
zone.tagname AS zone_tag,
station.tagname AS station_tag,
station.is_local AS station_is_local,
component.global_uuid::text AS component_uuid,
component.nspath AS component_nspath,
component.tag AS component_tag,
project.group_name AS attribute_group,
attribute.key AS attribute_name,
attribute.value::text AS attribute_value,
UPPER(pg_catalog.format_type(column_attribute.atttypid, column_attribute.atttypmod)) AS attribute_type,
attribute_description.description,
attribute_description.description_count,
dynamic_row.initialization_record_count AS dynamic_record_count
FROM public.grid AS grid
INNER JOIN public.zone AS zone ON zone.grid_id = grid.id
INNER JOIN public.station AS station ON station.zone_id = zone.id
INNER JOIN public.component AS component ON component.station_id = station.id
INNER JOIN public.project_manager AS project
ON project.tag = component.model_name
INNER JOIN dynamic_rows AS dynamic_row
ON dynamic_row.global_uuid = component.global_uuid
AND dynamic_row.attribute_group = project.group_name
CROSS JOIN LATERAL jsonb_each(
to_jsonb(dynamic_row)
- 'id'
- 'global_uuid'
- 'attribute_group'
- 'initialization_record_count'
) AS attribute
INNER JOIN pg_catalog.pg_namespace AS table_namespace
ON table_namespace.nspname = 'public'
INNER JOIN pg_catalog.pg_class AS parameter_table
ON parameter_table.relnamespace = table_namespace.oid
AND parameter_table.relname = project.name
INNER JOIN pg_catalog.pg_attribute AS column_attribute
ON column_attribute.attrelid = parameter_table.oid
AND column_attribute.attname = attribute.key
AND column_attribute.attnum > 0
AND NOT column_attribute.attisdropped
LEFT JOIN LATERAL (
SELECT
MIN(basic_attribute.attribute_name) AS description,
COUNT(*) AS description_count
FROM basic.attribute AS basic_attribute
WHERE basic_attribute.attribute = attribute.key
) AS attribute_description ON TRUE
WHERE project.name = ?
AND project.tag = ?
AND project.group_name = ?
AND grid.tagname <> ''
AND zone.tagname <> ''
AND station.tagname <> ''
AND component.nspath <> ''
AND component.tag <> ''`
// ComponentParameterInitializationRows expands the component table into one
// row per queryable component attribute while retaining the full hierarchy.
ComponentParameterInitializationRows = `SELECT
grid.tagname AS grid_tag,
zone.tagname AS zone_tag,
station.tagname AS station_tag,
station.is_local AS station_is_local,
component.global_uuid::text AS component_uuid,
component.nspath AS component_nspath,
component.tag AS component_tag,
'component' AS attribute_group,
attribute.key AS attribute_name,
attribute.value::text AS attribute_value,
UPPER(pg_catalog.format_type(column_attribute.atttypid, column_attribute.atttypmod)) AS attribute_type,
attribute_description.description,
attribute_description.description_count,
1::bigint AS dynamic_record_count
FROM public.grid AS grid
INNER JOIN public.zone AS zone ON zone.grid_id = grid.id
INNER JOIN public.station AS station ON station.zone_id = zone.id
INNER JOIN public.component AS component ON component.station_id = station.id
CROSS JOIN LATERAL jsonb_each(
to_jsonb(component) - 'station_id'
) AS attribute
INNER JOIN pg_catalog.pg_namespace AS table_namespace
ON table_namespace.nspname = 'public'
INNER JOIN pg_catalog.pg_class AS component_table
ON component_table.relnamespace = table_namespace.oid
AND component_table.relname = 'component'
INNER JOIN pg_catalog.pg_attribute AS column_attribute
ON column_attribute.attrelid = component_table.oid
AND column_attribute.attname = attribute.key
AND column_attribute.attnum > 0
AND NOT column_attribute.attisdropped
LEFT JOIN LATERAL (
SELECT
MIN(basic_attribute.attribute_name) AS description,
COUNT(*) AS description_count
FROM basic.attribute AS basic_attribute
WHERE basic_attribute.attribute = attribute.key
) AS attribute_description ON TRUE
WHERE grid.tagname <> ''
AND zone.tagname <> ''
AND station.tagname <> ''
AND component.nspath <> ''
AND component.tag <> ''`
)

View File

@ -1,4 +1,4 @@
// Package sql defines reusable database SQL statements // Package sql define database sql statement
package sql package sql
// RecursiveSQL define topologic table recursive query statement // RecursiveSQL define topologic table recursive query statement