Files
go-sip/internal/store/facts.go
T

127 lines
4.7 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
var ErrFactConflict = errors.New("execution fact content conflict")
type ExecutionFactRecord struct {
FactID string
TenantID string
TenantKey string
ExecutionID string
ContentSHA256 string
Kind int32
BindingJSON []byte
PayloadJSON []byte
ObservedAt time.Time
SourceBootID string
SourceSequence uint64
EventID string
EventType string
AggregateType string
AggregateID string
AggregateVersion int64
}
type ExecutionFactResult struct {
Duplicate bool
EventID string
}
type FactEventBuilder func(aggregateVersion int64) ([]byte, error)
// RecordExecutionFact persists an Agent fact and, when eventBuilder is set,
// the authoritative MQ event in one SQLite transaction. Aggregate versions are
// allocated by Dispatcher from durable state; the Agent cannot select them. A
// duplicate fact with the same digest is accepted without creating a second
// outbox row; reuse with a different digest is rejected.
func (s *Store) RecordExecutionFact(record ExecutionFactRecord, eventExchange, routingKey string, eventBuilder FactEventBuilder) (ExecutionFactResult, error) {
if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || record.ContentSHA256 == "" {
return ExecutionFactResult{}, errors.New("fact identity and tenant/execution binding are required")
}
if len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 {
return ExecutionFactResult{}, errors.New("fact binding and payload are required")
}
if record.ObservedAt.IsZero() {
return ExecutionFactResult{}, errors.New("fact observed time is required")
}
if eventBuilder != nil && record.EventID == "" {
return ExecutionFactResult{}, errors.New("event ID is required for outbox event")
}
if eventBuilder != nil && (eventExchange == "" || routingKey == "") {
return ExecutionFactResult{}, errors.New("event exchange and routing key are required for outbox event")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return ExecutionFactResult{}, err
}
defer tx.Rollback()
var existingDigest, existingEventID string
err = tx.QueryRow(`SELECT content_sha256, event_id FROM execution_facts WHERE fact_id = ?`, record.FactID).Scan(&existingDigest, &existingEventID)
if err == nil {
if existingDigest != record.ContentSHA256 {
return ExecutionFactResult{}, fmt.Errorf("%w: %s", ErrFactConflict, record.FactID)
}
if err := tx.Commit(); err != nil {
return ExecutionFactResult{}, err
}
return ExecutionFactResult{Duplicate: true, EventID: existingEventID}, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ExecutionFactResult{}, err
}
var eventBody []byte
if eventBuilder != nil {
var latestVersion sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(aggregate_version) FROM execution_facts WHERE aggregate_type = ? AND aggregate_id = ?`, record.AggregateType, record.AggregateID).Scan(&latestVersion); err != nil {
return ExecutionFactResult{}, err
}
record.AggregateVersion = latestVersion.Int64 + 1
if record.AggregateVersion < 1 {
record.AggregateVersion = 1
}
var buildErr error
eventBody, buildErr = eventBuilder(record.AggregateVersion)
if buildErr != nil {
return ExecutionFactResult{}, buildErr
}
if len(eventBody) == 0 {
return ExecutionFactResult{}, errors.New("event builder returned an empty body")
}
} else if record.AggregateVersion < 1 {
record.AggregateVersion = 1
}
now := s.now().UTC().Format(time.RFC3339Nano)
if _, err := tx.Exec(`INSERT INTO execution_facts(
fact_id, tenant_id, tenant_key, execution_id, content_sha256, kind,
binding_json, payload_json, observed_at, source_boot_id, source_sequence,
event_id, event_type, aggregate_type, aggregate_id, aggregate_version, received_at
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.FactID, record.TenantID, record.TenantKey, record.ExecutionID, record.ContentSHA256,
record.Kind, record.BindingJSON, record.PayloadJSON, record.ObservedAt.UTC().Format(time.RFC3339Nano),
record.SourceBootID, record.SourceSequence, record.EventID, record.EventType,
record.AggregateType, record.AggregateID, record.AggregateVersion, now); err != nil {
return ExecutionFactResult{}, err
}
if eventBuilder != nil {
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`,
record.EventID, record.TenantKey, eventExchange, routingKey, eventBody, now); err != nil {
return ExecutionFactResult{}, err
}
}
if err := tx.Commit(); err != nil {
return ExecutionFactResult{}, err
}
return ExecutionFactResult{EventID: record.EventID}, nil
}