127 lines
4.7 KiB
Go
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
|
|
}
|