diff --git a/cmds/core-service/main.go b/cmds/core-service/main.go index 6b4026fda..91ee224b1 100644 --- a/cmds/core-service/main.go +++ b/cmds/core-service/main.go @@ -368,9 +368,9 @@ func RunHTTPServer(ctx context.Context, ctxCanceler func(), address, locality st handler = authorizer.TokenMiddleware(handler) handler = http.TimeoutHandler(handler, *timeout, "request timeout") handler = logging.HTTPMiddleware(logger, *dumpRequests, handler) - handler = timestamp.RequestTimestampMiddleware(handler) + handler = timestamp.Middleware(handler) handler = random.Middleware(handler) - handler = requestlocality.LocalityMiddleware(locality)(handler) + handler = requestlocality.Middleware(locality)(handler) if *enableMetrics || *enableTracing { // We use the default settings; the APIRouter handler will override the span value accordingly, as it has more information. diff --git a/pkg/aux_/pool_participants.go b/pkg/aux_/pool_participants.go index 8a00c9f1f..dae417936 100644 --- a/pkg/aux_/pool_participants.go +++ b/pkg/aux_/pool_participants.go @@ -96,7 +96,7 @@ func (a *Server) PutDSSInstancesHeartbeat(ctx context.Context, req *restapi.PutD } heartbeat.Timestamp = &ts } else { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) heartbeat.Timestamp = &now } diff --git a/pkg/aux_/store/memstore/dss.go b/pkg/aux_/store/memstore/dss.go index ef90996f3..359beec9e 100644 --- a/pkg/aux_/store/memstore/dss.go +++ b/pkg/aux_/store/memstore/dss.go @@ -11,7 +11,7 @@ import ( ) func (r *repo) SaveOwnMetadata(ctx context.Context, loc string, publicEndpoint string) error { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) r.state.Participants[locality(loc)] = &participant{ PublicEndpoint: publicEndpoint, diff --git a/pkg/aux_/store/memstore/dss_test.go b/pkg/aux_/store/memstore/dss_test.go index f39bcb248..4c9a730a4 100644 --- a/pkg/aux_/store/memstore/dss_test.go +++ b/pkg/aux_/store/memstore/dss_test.go @@ -15,7 +15,7 @@ var fakeClock = clockwork.NewFakeClock() func TestSaveOwnMetadataRoundTrip(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() require.NoError(t, r.SaveOwnMetadata(ctx, "dss-1", "https://example.com")) @@ -35,7 +35,7 @@ func TestSaveOwnMetadataRoundTrip(t *testing.T) { func TestSaveOwnMetadataUpsert(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() require.NoError(t, r.SaveOwnMetadata(ctx, "dss-1", "https://old.example.com")) @@ -50,7 +50,7 @@ func TestSaveOwnMetadataUpsert(t *testing.T) { func TestGetDSSMetadataPicksLatestHeartbeat(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() require.NoError(t, r.SaveOwnMetadata(ctx, "dss-1", "https://example.com")) @@ -71,7 +71,7 @@ func TestGetDSSMetadataPicksLatestHeartbeat(t *testing.T) { func TestGetDSSMetadataUpdatesHeartbeatPerSource(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() require.NoError(t, r.SaveOwnMetadata(ctx, "dss-1", "https://example.com")) diff --git a/pkg/aux_/store/memstore/snapshot_test.go b/pkg/aux_/store/memstore/snapshot_test.go index cd2f03a9f..1ff9015b4 100644 --- a/pkg/aux_/store/memstore/snapshot_test.go +++ b/pkg/aux_/store/memstore/snapshot_test.go @@ -17,7 +17,7 @@ import ( func TestSnapshotRoundTrip(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) src := newRepo() require.NoError(t, src.SaveOwnMetadata(ctx, "dss-1", "https://example.com")) ts := time.Now().UTC() @@ -40,7 +40,7 @@ func TestSnapshotRoundTrip(t *testing.T) { func TestRestoreFromSnapshotReplacesState(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) src := newRepo() require.NoError(t, src.SaveOwnMetadata(ctx, "dss-1", "https://example.com")) data, err := src.GetSnapshot() diff --git a/pkg/aux_/store/memstore/store_test.go b/pkg/aux_/store/memstore/store_test.go index 1526efb04..70376edb3 100644 --- a/pkg/aux_/store/memstore/store_test.go +++ b/pkg/aux_/store/memstore/store_test.go @@ -10,7 +10,7 @@ import ( func TestCheckpointRestore(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() @@ -34,7 +34,7 @@ func TestCheckpointRestore(t *testing.T) { func TestCheckpointIsolatesUpsert(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) r := newRepo() require.NoError(t, r.SaveOwnMetadata(ctx, "dss-1", "https://old.example.com")) diff --git a/pkg/aux_/store/raftstore/store.go b/pkg/aux_/store/raftstore/store.go index 8f8a4270f..7b55231ed 100644 --- a/pkg/aux_/store/raftstore/store.go +++ b/pkg/aux_/store/raftstore/store.go @@ -24,8 +24,7 @@ const ( // repo is a full implementation of aux_.repos.Repository for Raft-based storage. type repo struct { consensus *consensus.Consensus - memStore *memstore.Store[repos.Repository] - memRepo repos.Repository + *memstore.Store[repos.Repository] } func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore.Store[repos.Repository], error) { @@ -39,7 +38,7 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. return nil, stacktrace.Propagate(err, "failed to initialize aux memstore") } - r := &repo{memStore: memStore, memRepo: memStore.GetRepo()} + r := &repo{Store: memStore} store, err := raftstore.Init(ctx, logger.With(zap.String("service", "aux_")), locality, params, r, nil) if err != nil { return nil, stacktrace.Propagate(err, "failed to initialize aux raftstore") @@ -52,14 +51,6 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. func (r *repo) GetRepo() repos.Repository { return r } -func (r *repo) GetSnapshot() ([]byte, error) { - return r.memStore.GetSnapshot() -} - -func (r *repo) RestoreFromSnapshot(data []byte) error { - return r.memStore.RestoreFromSnapshot(data) -} - func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, error) { switch proposal.RequestType { case saveOwnMetadata: @@ -68,10 +59,10 @@ func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, err return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", saveOwnMetadata) } - return nil, r.memRepo.SaveOwnMetadata(ctx, payload.Locality, payload.PublicEndpoint) + return nil, r.Store.GetRepo().SaveOwnMetadata(ctx, payload.Locality, payload.PublicEndpoint) case getDSSMetadata: - return r.memRepo.GetDSSMetadata(ctx) + return r.Store.GetRepo().GetDSSMetadata(ctx) case recordHeartbeat: var heartbeat auxmodels.Heartbeat @@ -79,7 +70,7 @@ func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, err return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", recordHeartbeat) } - return nil, r.memRepo.RecordHeartbeat(ctx, heartbeat) + return nil, r.Store.GetRepo().RecordHeartbeat(ctx, heartbeat) default: return nil, stacktrace.NewError("unknown request type: %q", proposal.RequestType) diff --git a/pkg/locality/locality.go b/pkg/locality/locality.go index 8ea51faa8..6e1454c85 100644 --- a/pkg/locality/locality.go +++ b/pkg/locality/locality.go @@ -7,12 +7,12 @@ import ( "github.com/interuss/stacktrace" ) -type localityKey struct{} +type key struct{} -// MustGetRequestLocality returns the request locality from the context and panics if it is not +// MustFromContext returns the request locality from the context and panics if it is not // present, which is a programming error. -func MustGetRequestLocality(ctx context.Context) string { - locality, ok := ctx.Value(localityKey{}).(string) +func MustFromContext(ctx context.Context) string { + locality, ok := ctx.Value(key{}).(string) if !ok { panic(stacktrace.NewError("request locality not present in context")) } @@ -20,17 +20,17 @@ func MustGetRequestLocality(ctx context.Context) string { return locality } -// WithRequestLocality returns a new context with the given locality. -func WithRequestLocality(ctx context.Context, locality string) context.Context { - return context.WithValue(ctx, localityKey{}, locality) +// NewContext returns a new context with the given locality. +func NewContext(ctx context.Context, locality string) context.Context { + return context.WithValue(ctx, key{}, locality) } -// LocalityMiddleware is an HTTP middleware that stamps each incoming request with this +// Middleware is an HTTP middleware that stamps each incoming request with this // DSS instance's locality so that locality-dependent operations execute deterministically across nodes. -func LocalityMiddleware(locality string) func(http.Handler) http.Handler { +func Middleware(locality string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r.WithContext(WithRequestLocality(r.Context(), locality))) + next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), locality))) }) } } diff --git a/pkg/models/geo.go b/pkg/models/geo.go index f52c5ae0d..df7fc3059 100644 --- a/pkg/models/geo.go +++ b/pkg/models/geo.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "time" "github.com/golang/geo/s2" @@ -46,6 +47,83 @@ type Volume3D struct { Footprint Geometry } +type Volume3DJSON struct { + AltitudeHi *float32 `json:"altitude_hi,omitempty"` + AltitudeLo *float32 `json:"altitude_lo,omitempty"` + Footprint *geometryJSON `json:"footprint,omitempty"` +} + +type geometryType string + +const ( + circle geometryType = "circle" + polygon geometryType = "polygon" + cells geometryType = "cells" +) + +// geometryJSON is a helper struct for marshaling and unmarshaling Geometry types to/from JSON. +type geometryJSON struct { + Type geometryType `json:"type"` + Polygon *GeoPolygon `json:"polygon,omitempty"` + Circle *GeoCircle `json:"circle,omitempty"` + Cells []s2.CellID `json:"cells,omitempty"` +} + +func (v Volume3D) MarshalJSON() ([]byte, error) { + w := Volume3DJSON{AltitudeHi: v.AltitudeHi, AltitudeLo: v.AltitudeLo} + if v.Footprint != nil { + switch f := v.Footprint.(type) { + case *GeoPolygon: + w.Footprint = &geometryJSON{Type: polygon, Polygon: f} + + case *GeoCircle: + w.Footprint = &geometryJSON{Type: circle, Circle: f} + + case precomputedCellGeometry: + cellsResult := make([]s2.CellID, 0, len(f)) + for id := range f { + cellsResult = append(cellsResult, id) + } + w.Footprint = &geometryJSON{Type: cells, Cells: cellsResult} + + default: + return nil, stacktrace.NewError("Volume3D: unsupported Footprint type %T for JSON marshaling", v.Footprint) + } + } + + return json.Marshal(w) +} + +func (v *Volume3D) UnmarshalJSON(data []byte) error { + var w Volume3DJSON + if err := json.Unmarshal(data, &w); err != nil { + return err + } + v.AltitudeHi = w.AltitudeHi + v.AltitudeLo = w.AltitudeLo + if w.Footprint != nil { + switch w.Footprint.Type { + case polygon: + v.Footprint = w.Footprint.Polygon + + case circle: + v.Footprint = w.Footprint.Circle + + case cells: + pcg := make(precomputedCellGeometry, len(w.Footprint.Cells)) + for _, id := range w.Footprint.Cells { + pcg[id] = struct{}{} + } + + v.Footprint = pcg + default: + return stacktrace.NewError("Volume3D: unknown geometry type %q", w.Footprint.Type) + } + } + + return nil +} + // Geometry models a geometry. type Geometry interface { // CalculateCovering returns an s2 cell covering for a geometry. diff --git a/pkg/models/models.go b/pkg/models/models.go index 18bc8c29b..84be5be9e 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "strconv" "time" @@ -175,3 +176,26 @@ func (v *Version) ToTimestamp() *time.Time { } return &v.t } + +func (v *Version) MarshalJSON() ([]byte, error) { + return json.Marshal(v.String()) +} + +func (v *Version) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + if s == "" { + return nil + } + + parsed, err := VersionFromString(s) + if err != nil { + return stacktrace.Propagate(err, "failed to unmarshal version") + } + + *v = *parsed + return nil +} diff --git a/pkg/raftstore/consensus/proposal.go b/pkg/raftstore/consensus/proposal.go index bb63fda74..332a842e8 100644 --- a/pkg/raftstore/consensus/proposal.go +++ b/pkg/raftstore/consensus/proposal.go @@ -35,7 +35,7 @@ type Proposal struct { } func (c *Consensus) newProposal(ctx context.Context, requestType RequestType, value []byte, readOnly bool) Proposal { - timestamp := timestamp.MustGetRequestTimestamp(ctx) + timestamp := timestamp.MustFromContext(ctx) seed := random.MustFromContext(ctx) return Proposal{ diff --git a/pkg/raftstore/store.go b/pkg/raftstore/store.go index a17c00827..436f4bf8f 100644 --- a/pkg/raftstore/store.go +++ b/pkg/raftstore/store.go @@ -5,6 +5,7 @@ import ( "github.com/interuss/dss/pkg/locality" "github.com/interuss/dss/pkg/logging" + "github.com/interuss/dss/pkg/memstore" "github.com/interuss/dss/pkg/raftstore/consensus" raftparams "github.com/interuss/dss/pkg/raftstore/params" "github.com/interuss/dss/pkg/random" @@ -15,19 +16,12 @@ import ( ) type RaftRepo[R any] interface { - GetRepo() R + memstore.MemRepo[R] + // Apply is called on every committed entry. The proposal must be applied atomically. // The any return mirrors store.OperationHandler.Execute: different requests yield different // concrete result types. Callers recover the type via store.TransactWithResult. Apply(ctx context.Context, proposal consensus.Proposal) (any, error) - - // GetSnapshot returns a serialized view of current state, suitable - // for restoring via RestoreFromSnapshot. - GetSnapshot() ([]byte, error) - - // RestoreFromSnapshot replaces all state with the snapshot in data. - // data is always the output of a prior GetSnapshot. - RestoreFromSnapshot(data []byte) error } type Store[R any] struct { @@ -116,10 +110,15 @@ func (s *Store[R]) processCommits(ctx context.Context, commitCh <-chan consensus continue } - proposalCtx := timestamp.WithRequestTimestamp(ctx, commit.Prop.Timestamp) - proposalCtx = locality.WithRequestLocality(proposalCtx, commit.Prop.Locality) + proposalCtx := timestamp.NewContext(ctx, commit.Prop.Timestamp) + proposalCtx = locality.NewContext(proposalCtx, commit.Prop.Locality) proposalCtx = random.NewContext(proposalCtx, commit.Prop.Seed) + s.raftRepo.Checkpoint() result, err := s.raftRepo.Apply(proposalCtx, commit.Prop) + if err != nil { + s.logger.Warn("failed to apply proposal, rolling back", zap.String("proposal_id", commit.Prop.ID), zap.String("proposal_type", string(commit.Prop.RequestType)), zap.Error(err)) + s.raftRepo.Restore() + } commit.Done <- consensus.ProposalResult{Result: result, Error: err} } } diff --git a/pkg/rid/actions/registry.go b/pkg/rid/operations/registry.go similarity index 92% rename from pkg/rid/actions/registry.go rename to pkg/rid/operations/registry.go index 2cd22af51..989928349 100644 --- a/pkg/rid/actions/registry.go +++ b/pkg/rid/operations/registry.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "github.com/interuss/dss/pkg/rid/repos" diff --git a/pkg/rid/actions/subscription.go b/pkg/rid/operations/subscription.go similarity index 94% rename from pkg/rid/actions/subscription.go rename to pkg/rid/operations/subscription.go index e3537b402..dee5fac7e 100644 --- a/pkg/rid/actions/subscription.go +++ b/pkg/rid/operations/subscription.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "context" @@ -16,16 +16,16 @@ func init() { Registry[ridv1.DeleteSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*ridv1.DeleteSubscriptionRequest], - Execute: ExecuteDeleteSubscription, + Execute: executeDeleteSubscription, } Registry[ridv2.DeleteSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*ridv2.DeleteSubscriptionRequest], - Execute: ExecuteDeleteSubscription, + Execute: executeDeleteSubscription, } } -func ExecuteDeleteSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeDeleteSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { var ( rawID string rawVersion string diff --git a/pkg/rid/store/memstore/identification_service_area.go b/pkg/rid/store/memstore/identification_service_area.go index bf7bb042b..02976cc69 100644 --- a/pkg/rid/store/memstore/identification_service_area.go +++ b/pkg/rid/store/memstore/identification_service_area.go @@ -61,7 +61,7 @@ func (r *repo) InsertISA(ctx context.Context, isa *ridmodels.IdentificationServi return nil, stacktrace.NewError("ISA with id %s already exists", isa.ID) } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := isaRecordFromModel(isa, now) r.state.ISAs[isa.ID] = rec @@ -77,7 +77,7 @@ func (r *repo) UpdateISA(ctx context.Context, isa *ridmodels.IdentificationServi return nil, nil } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := isaRecordFromModel(isa, now) rec.Owner = prev.Owner // It's not possible to update the owner of an ISA, this ensure it's to changed to a new value. diff --git a/pkg/rid/store/memstore/identification_service_area_test.go b/pkg/rid/store/memstore/identification_service_area_test.go index f6baa8dbf..a9bb4b34f 100644 --- a/pkg/rid/store/memstore/identification_service_area_test.go +++ b/pkg/rid/store/memstore/identification_service_area_test.go @@ -34,7 +34,7 @@ var ( func TestStoreSearchISAs(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) cells := s2.CellUnion{ s2.CellID(17106221850767130624), s2.CellID(17106221885126868992), @@ -137,7 +137,7 @@ func TestStoreSearchISAs(t *testing.T) { func TestBadVersion(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) saOut1, err := repo.InsertISA(ctx, serviceArea) @@ -159,7 +159,7 @@ func TestBadVersion(t *testing.T) { func TestStoreExpiredISA(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) saOut, err := repo.InsertISA(ctx, serviceArea) @@ -194,7 +194,7 @@ func TestStoreExpiredISA(t *testing.T) { func TestStoreDeleteISAs(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) // Insert the ISA. @@ -215,7 +215,7 @@ func TestStoreDeleteISAs(t *testing.T) { func TestStoreISAWithNoGeoData(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) endTime := fakeClock.Now().Add(24 * time.Hour) @@ -230,7 +230,7 @@ func TestStoreISAWithNoGeoData(t *testing.T) { func TestListExpiredISAs(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) // Insert ISA with endtime 1 day from now @@ -261,7 +261,7 @@ func TestListExpiredISAs(t *testing.T) { func TestListExpiredISAsWithEmptyWriter(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) // Insert ISA with endtime 1 day from now @@ -294,7 +294,7 @@ func TestListExpiredISAsWithEmptyWriter(t *testing.T) { func TestStoreCountISAs(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) // Insert the ISA. diff --git a/pkg/rid/store/memstore/snapshot_test.go b/pkg/rid/store/memstore/snapshot_test.go index a795d5f5f..50fc5423d 100644 --- a/pkg/rid/store/memstore/snapshot_test.go +++ b/pkg/rid/store/memstore/snapshot_test.go @@ -15,7 +15,7 @@ import ( func TestSnapshotRoundTrip(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) src := setUpStore(t) _, err := src.InsertISA(ctx, serviceArea) require.NoError(t, err) @@ -49,7 +49,7 @@ func TestSnapshotRoundTrip(t *testing.T) { func TestRestoreFromSnapshotReplacesState(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) src := setUpStore(t) _, err := src.InsertISA(ctx, serviceArea) require.NoError(t, err) diff --git a/pkg/rid/store/memstore/store_test.go b/pkg/rid/store/memstore/store_test.go index 62752a537..55d13081f 100644 --- a/pkg/rid/store/memstore/store_test.go +++ b/pkg/rid/store/memstore/store_test.go @@ -30,7 +30,7 @@ func setUpStore(t *testing.T) *repo { func TestDatabaseEnsuresBeginsBeforeExpires(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) var ( @@ -50,7 +50,7 @@ func TestDatabaseEnsuresBeginsBeforeExpires(t *testing.T) { func TestCheckpointRestoreISA(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) _, err := repo.InsertISA(ctx, serviceArea) @@ -76,7 +76,7 @@ func TestCheckpointRestoreISA(t *testing.T) { func TestCheckpointIsolatesNotificationIndex(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) sub, err := repo.InsertSubscription(ctx, subscriptionsPool[0].input) diff --git a/pkg/rid/store/memstore/subscriptions.go b/pkg/rid/store/memstore/subscriptions.go index 63cb22c09..f5a7bffdb 100644 --- a/pkg/rid/store/memstore/subscriptions.go +++ b/pkg/rid/store/memstore/subscriptions.go @@ -67,7 +67,7 @@ func (r *repo) InsertSubscription(ctx context.Context, s *ridmodels.Subscription return nil, stacktrace.NewError("Subscription with id %s already exists", s.ID) } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := subRecordFromModel(s, now) r.state.Subscriptions[s.ID] = rec @@ -83,7 +83,7 @@ func (r *repo) UpdateSubscription(ctx context.Context, s *ridmodels.Subscription return nil, nil } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := subRecordFromModel(s, now) rec.Owner = prev.Owner // It's not possible to update the owner of a subscription, this ensure it's to changed to a new value. @@ -137,7 +137,7 @@ func (r *repo) searchSubscriptions(ctx context.Context, cells s2.CellUnion, owne return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "no location provided") } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) var out []*ridmodels.Subscription for rec := range r.liveSubscriptionsInCells(now, cells, owner) { @@ -154,7 +154,7 @@ func (r *repo) searchSubscriptions(ctx context.Context, cells s2.CellUnion, owne // subscription in the given cells. func (r *repo) UpdateNotificationIdxsInCells(ctx context.Context, cells s2.CellUnion) ([]*ridmodels.Subscription, error) { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) var out []*ridmodels.Subscription for rec := range r.liveSubscriptionsInCells(now, cells, nil) { @@ -166,7 +166,7 @@ func (r *repo) UpdateNotificationIdxsInCells(ctx context.Context, cells s2.CellU func (r *repo) MaxSubscriptionCountInCellsByOwner(ctx context.Context, cells s2.CellUnion, owner dssmodels.Owner) (int, error) { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) want := cellSet(cells) counts := make(map[s2.CellID]int, len(cells)) diff --git a/pkg/rid/store/memstore/subscriptions_test.go b/pkg/rid/store/memstore/subscriptions_test.go index 1d61cf707..993aaeff8 100644 --- a/pkg/rid/store/memstore/subscriptions_test.go +++ b/pkg/rid/store/memstore/subscriptions_test.go @@ -70,7 +70,7 @@ var ( func TestStoreGetSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) for _, r := range subscriptionsPool { @@ -90,7 +90,7 @@ func TestStoreGetSubscription(t *testing.T) { func TestStoreInsertSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) for _, r := range subscriptionsPool { @@ -134,7 +134,7 @@ func TestStoreInsertSubscription(t *testing.T) { func TestStoreDeleteSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) for _, r := range subscriptionsPool { @@ -162,7 +162,7 @@ func TestStoreDeleteSubscription(t *testing.T) { func TestStoreSearchSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now().UTC()) + ctx = timestamp.NewContext(ctx, fakeClock.Now().UTC()) repo := setUpStore(t) var ( @@ -207,7 +207,7 @@ func TestStoreSearchSubscription(t *testing.T) { func TestStoreExpiredSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) endTime := fakeClock.Now().Add(24 * time.Hour) @@ -221,7 +221,7 @@ func TestStoreExpiredSubscription(t *testing.T) { require.NoError(t, err) // The subscription's endTime is 24 hours from now. - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now().Add(23*time.Hour)) + ctx = timestamp.NewContext(ctx, fakeClock.Now().Add(23*time.Hour)) // We should still be able to find the subscription by searching and by ID. subs, err := repo.SearchSubscriptionsByOwner(ctx, sub.Cells, "original owner") @@ -233,7 +233,7 @@ func TestStoreExpiredSubscription(t *testing.T) { require.NotNil(t, &ret) // But now the subscription has expired. - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now().Add(25*time.Hour)) + ctx = timestamp.NewContext(ctx, fakeClock.Now().Add(25*time.Hour)) subs, err = repo.SearchSubscriptionsByOwner(ctx, sub.Cells, "original owner") require.NoError(t, err) @@ -246,7 +246,7 @@ func TestStoreExpiredSubscription(t *testing.T) { func TestStoreSubscriptionWithNoGeoData(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) endTime := fakeClock.Now().Add(24 * time.Hour) @@ -261,7 +261,7 @@ func TestStoreSubscriptionWithNoGeoData(t *testing.T) { func TestMaxSubscriptionCountInCellsByOwner(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) for _, s := range subscriptionsPool { @@ -276,7 +276,7 @@ func TestMaxSubscriptionCountInCellsByOwner(t *testing.T) { func TestListExpiredSubscriptions(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) fakeClock := clockwork.NewFakeClockAt(time.Now()) @@ -309,7 +309,7 @@ func TestListExpiredSubscriptions(t *testing.T) { func TestListExpiredSubscriptionsWithEmptyWriter(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) // Insert Subscription with endtime 1 day from now @@ -342,7 +342,7 @@ func TestListExpiredSubscriptionsWithEmptyWriter(t *testing.T) { func TestStoreCountSubscription(t *testing.T) { ctx := context.Background() - ctx = timestamp.WithRequestTimestamp(ctx, fakeClock.Now()) + ctx = timestamp.NewContext(ctx, fakeClock.Now()) repo := setUpStore(t) for _, r := range subscriptionsPool { diff --git a/pkg/rid/store/raftstore/identification_service_area.go b/pkg/rid/store/raftstore/identification_service_area.go index b9f7222a5..55ff39b80 100644 --- a/pkg/rid/store/raftstore/identification_service_area.go +++ b/pkg/rid/store/raftstore/identification_service_area.go @@ -2,39 +2,181 @@ package raftstore import ( "context" + "encoding/json" "time" "github.com/golang/geo/s2" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/raftstore/consensus" ridmodels "github.com/interuss/dss/pkg/rid/models" "github.com/interuss/stacktrace" ) -func (r *repo) GetISA(_ context.Context, id dssmodels.ID, forUpdate bool) (*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetISA not implemented for raftstore") +const ( + getISA consensus.RequestType = "getISA" + deleteISA consensus.RequestType = "deleteISA" + insertISA consensus.RequestType = "insertISA" + updateISA consensus.RequestType = "updateISA" + searchISAs consensus.RequestType = "searchISAs" + listExpiredISAs consensus.RequestType = "listExpiredISAs" + countISAs consensus.RequestType = "countISAs" +) + +func (r *repo) GetISA(ctx context.Context, id dssmodels.ID, _ bool) (*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(id) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getISA, buf, true) + if err != nil { + return nil, err + } + if isa, ok := result.(*ridmodels.IdentificationServiceArea); ok { + return isa, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) DeleteISA(_ context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteISA not implemented for raftstore") +func (r *repo) DeleteISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(isa) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, deleteISA, buf, false) + if err != nil { + return nil, err + } + if deleted, ok := result.(*ridmodels.IdentificationServiceArea); ok { + return deleted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) InsertISA(_ context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "InsertISA not implemented for raftstore") +func (r *repo) InsertISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(isa) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, insertISA, buf, false) + if err != nil { + return nil, err + } + if inserted, ok := result.(*ridmodels.IdentificationServiceArea); ok { + return inserted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) UpdateISA(_ context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpdateISA not implemented for raftstore") +func (r *repo) UpdateISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(isa) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, updateISA, buf, false) + if err != nil { + return nil, err + } + if updated, ok := result.(*ridmodels.IdentificationServiceArea); ok { + return updated, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) SearchISAs(_ context.Context, cells s2.CellUnion, earliest *time.Time, latest *time.Time) ([]*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchISAs not implemented for raftstore") +func (r *repo) SearchISAs(ctx context.Context, cells s2.CellUnion, earliest *time.Time, latest *time.Time) ([]*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(searchISAsPayload{Cells: cells, Earliest: earliest, Latest: latest}) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, searchISAs, buf, true) + if err != nil { + return nil, err + } + if isas, ok := result.([]*ridmodels.IdentificationServiceArea); ok { + return isas, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) ListExpiredISAs(_ context.Context, writer string, threshold time.Time) ([]*ridmodels.IdentificationServiceArea, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "ListExpiredISAs not implemented for raftstore") +func (r *repo) ListExpiredISAs(ctx context.Context, writer string, threshold time.Time) ([]*ridmodels.IdentificationServiceArea, error) { + buf, err := json.Marshal(expiredPayload{Writer: writer, Threshold: threshold}) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, listExpiredISAs, buf, true) + if err != nil { + return nil, err + } + if isas, ok := result.([]*ridmodels.IdentificationServiceArea); ok { + return isas, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) CountISAs(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountISAs not implemented for raftstore") +func (r *repo) CountISAs(ctx context.Context) (int64, error) { + result, err := r.consensus.HandleClientRequest(ctx, countISAs, nil, true) + if err != nil { + return 0, err + } + if count, ok := result.(int64); ok { + return count, nil + } + return 0, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) applyISA(ctx context.Context, proposal consensus.Proposal) (any, error) { + switch proposal.RequestType { + case getISA: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getISA) + } + return r.Store.GetRepo().GetISA(ctx, id, false) + + case deleteISA: + var isa ridmodels.IdentificationServiceArea + if err := json.Unmarshal(proposal.Value, &isa); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", deleteISA) + } + return r.Store.GetRepo().DeleteISA(ctx, &isa) + + case insertISA: + var isa ridmodels.IdentificationServiceArea + if err := json.Unmarshal(proposal.Value, &isa); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", insertISA) + } + return r.Store.GetRepo().InsertISA(ctx, &isa) + + case updateISA: + var isa ridmodels.IdentificationServiceArea + if err := json.Unmarshal(proposal.Value, &isa); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", updateISA) + } + return r.Store.GetRepo().UpdateISA(ctx, &isa) + + case searchISAs: + var payload searchISAsPayload + if err := json.Unmarshal(proposal.Value, &payload); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", searchISAs) + } + return r.Store.GetRepo().SearchISAs(ctx, payload.Cells, payload.Earliest, payload.Latest) + + case listExpiredISAs: + var payload expiredPayload + if err := json.Unmarshal(proposal.Value, &payload); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", listExpiredISAs) + } + return r.Store.GetRepo().ListExpiredISAs(ctx, payload.Writer, payload.Threshold) + + case countISAs: + return r.Store.GetRepo().CountISAs(ctx) + + default: + return nil, stacktrace.NewError("unrecognized ISA request type: %s", proposal.RequestType) + } } diff --git a/pkg/rid/store/raftstore/payloads.go b/pkg/rid/store/raftstore/payloads.go new file mode 100644 index 000000000..040a1788e --- /dev/null +++ b/pkg/rid/store/raftstore/payloads.go @@ -0,0 +1,20 @@ +package raftstore + +import ( + "time" + + "github.com/golang/geo/s2" +) + +// expiredPayload carries the arguments common to ListExpiredISAs/ListExpiredSubscriptions. +type expiredPayload struct { + Writer string `json:"writer"` + Threshold time.Time `json:"threshold"` +} + +// searchISAsPayload carries the arguments of SearchISAs. +type searchISAsPayload struct { + Cells s2.CellUnion `json:"cells"` + Earliest *time.Time `json:"earliest,omitempty"` + Latest *time.Time `json:"latest,omitempty"` +} diff --git a/pkg/rid/store/raftstore/store.go b/pkg/rid/store/raftstore/store.go index 6dabdecf7..2c22d87ac 100644 --- a/pkg/rid/store/raftstore/store.go +++ b/pkg/rid/store/raftstore/store.go @@ -6,7 +6,7 @@ import ( "github.com/interuss/dss/pkg/memstore" "github.com/interuss/dss/pkg/raftstore" "github.com/interuss/dss/pkg/raftstore/consensus" - "github.com/interuss/dss/pkg/rid/actions" + "github.com/interuss/dss/pkg/rid/operations" "github.com/interuss/dss/pkg/rid/repos" ridmemstore "github.com/interuss/dss/pkg/rid/store/memstore" ridraftparams "github.com/interuss/dss/pkg/rid/store/raftstore/params" @@ -17,8 +17,7 @@ import ( // repo is a full implementation of rid.repos.Repository for Raft-based storage. type repo struct { consensus *consensus.Consensus - memStore *memstore.Store[repos.Repository] - memRepo repos.Repository + *memstore.Store[repos.Repository] } func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore.Store[repos.Repository], error) { @@ -32,8 +31,8 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. return nil, stacktrace.Propagate(err, "failed to initialize rid memstore") } - r := &repo{memStore: memStore, memRepo: memStore.GetRepo()} - store, err := raftstore.Init(ctx, logger.With(zap.String("service", "rid")), locality, params, r, actions.Registry) + r := &repo{Store: memStore} + store, err := raftstore.Init(ctx, logger.With(zap.String("service", "rid")), locality, params, r, operations.Registry) if err != nil { return nil, stacktrace.Propagate(err, "failed to initialize rid raftstore") } @@ -45,19 +44,13 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. func (r *repo) GetRepo() repos.Repository { return r } -func (r *repo) GetSnapshot() ([]byte, error) { - return r.memStore.GetSnapshot() -} - -func (r *repo) RestoreFromSnapshot(data []byte) error { - return r.memStore.RestoreFromSnapshot(data) -} - func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, error) { switch proposal.RequestType { + case getISA, deleteISA, insertISA, updateISA, searchISAs, listExpiredISAs, countISAs: + return r.applyISA(ctx, proposal) default: - handler, ok := actions.Registry[string(proposal.RequestType)] + handler, ok := operations.Registry[string(proposal.RequestType)] if !ok { return nil, stacktrace.NewError("unrecognized request type: %s", proposal.RequestType) } @@ -67,6 +60,6 @@ func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, err return nil, stacktrace.Propagate(err, "failed to decode %s payload", proposal.RequestType) } - return handler.Execute(ctx, r.memRepo, request) + return handler.Execute(ctx, r.Store.GetRepo(), request) } } diff --git a/pkg/rid/store/sqlstore/store.go b/pkg/rid/store/sqlstore/store.go index 20bb2e026..e51eb1662 100644 --- a/pkg/rid/store/sqlstore/store.go +++ b/pkg/rid/store/sqlstore/store.go @@ -6,7 +6,7 @@ import ( dssql "github.com/interuss/dss/pkg/sql" "github.com/interuss/dss/pkg/logging" - "github.com/interuss/dss/pkg/rid/actions" + "github.com/interuss/dss/pkg/rid/operations" "github.com/interuss/dss/pkg/rid/repos" "github.com/interuss/dss/pkg/sqlstore" "github.com/interuss/dss/pkg/store/params" @@ -45,6 +45,6 @@ func Init(ctx context.Context, logger *zap.Logger, withCheckCron bool) (*sqlstor timeBasedNotificationIndex: opts.TimeBasedNotificationIndex, } }, - Registry: actions.Registry, + Registry: operations.Registry, }, withCheckCron) } diff --git a/pkg/scd/actions/operational_intents.go b/pkg/scd/actions/operational_intents.go deleted file mode 100644 index c77c60877..000000000 --- a/pkg/scd/actions/operational_intents.go +++ /dev/null @@ -1,236 +0,0 @@ -package actions - -import ( - "context" - - "github.com/golang/geo/s2" - restapi "github.com/interuss/dss/pkg/api/scdv1" - dsserr "github.com/interuss/dss/pkg/errors" - dssmodels "github.com/interuss/dss/pkg/models" - scdmodels "github.com/interuss/dss/pkg/scd/models" - "github.com/interuss/dss/pkg/scd/repos" - dssstore "github.com/interuss/dss/pkg/store" - "github.com/interuss/stacktrace" -) - -func init() { - Registry[restapi.GetOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ - Encode: dssstore.EncodeJSON, - Decode: dssstore.DecodeJSON[*restapi.GetOperationalIntentReferenceRequest], - Execute: ExecuteGetOperationalIntentReference, - IsReadOnly: true, - } - Registry[restapi.QueryOperationalIntentReferencesOperationID] = dssstore.OperationHandler[repos.Repository]{ - Encode: dssstore.EncodeJSON, - Decode: dssstore.DecodeJSON[*restapi.QueryOperationalIntentReferencesRequest], - Execute: ExecuteQueryOperationalIntentReferences, - IsReadOnly: true, - } - Registry[restapi.DeleteOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ - Encode: dssstore.EncodeJSON, - Decode: dssstore.DecodeJSON[*restapi.DeleteOperationalIntentReferenceRequest], - Execute: ExecuteDeleteOperationalIntentReference, - } -} - -// SubscriptionIsImplicitAndOnlyAttachedToOIR will check if: -// - the subscription is defined and is implicit -// - the subscription is attached to the specified operational intent -// - the subscription is not attached to any other operational intent -// -// This is to be used in contexts where an implicit subscription may need to be cleaned up: if true is returned, -// the subscription can be safely removed after the operational intent is deleted or attached to another subscription. -// -// NOTE: this should eventually be pushed down the datastore as part of the queries being executed in the callers of this method. -// -// See https://github.com/interuss/dss/issues/1059 for more details -func SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx context.Context, r repos.Repository, oirID dssmodels.ID, subscription *scdmodels.Subscription) (bool, error) { - if subscription == nil { - return false, nil - } - if !subscription.ImplicitSubscription { - return false, nil - } - // Get the Subscription's dependent OperationalIntents - dependentOps, err := r.GetDependentOperationalIntents(ctx, subscription.ID) - if err != nil { - return false, stacktrace.Propagate(err, "Could not find dependent OperationalIntents") - } - if len(dependentOps) == 0 { - return false, stacktrace.NewError("An implicit Subscription had no dependent OperationalIntents") - } else if len(dependentOps) == 1 && dependentOps[0] == oirID { - return true, nil - } - return false, nil -} - -// ExecuteDeleteOperationalIntentReference deletes a single operational intent ref for a given ID -// at the specified version. -func ExecuteDeleteOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { - req, ok := request.(*restapi.DeleteOperationalIntentReferenceRequest) - if !ok { - return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.DeleteOperationalIntentReferenceOperationID) - } - - // Retrieve OperationalIntent ID - id, err := dssmodels.IDFromString(string(req.Entityid)) - if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format: `%s`", req.Entityid) - } - - // Get OperationalIntent to delete - old, err := repo.GetOperationalIntent(ctx, id) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to get OperationIntent from repo") - } - if old == nil { - return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent %s not found", id) - } - - // Validate deletion request - if old.Manager != dssmodels.Manager(*req.Auth.ClientID) { - return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, - "OperationalIntent owned by %s, but %s attempted to delete", old.Manager, *req.Auth.ClientID) - } - - if old.OVN != scdmodels.OVN(req.Ovn) { - return nil, stacktrace.NewErrorWithCode(dsserr.VersionMismatch, - "Current version is %s but client specified version %s", old.OVN, scdmodels.OVN(req.Ovn)) - } - - // Lock subscriptions based on the cell and subscriptions we're going to use - // to reduce the number of retries under concurrent load. - // See issue #1002 for details. - var subscriptionIds = make([]dssmodels.ID, 0) - - if old.SubscriptionID != nil { - subscriptionIds = append(subscriptionIds, *old.SubscriptionID) - } - - err = repo.LockSubscriptionsOnCells(ctx, old.Cells, subscriptionIds, old.StartTime, old.EndTime) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to acquire lock") - } - - // Get the Subscription supporting the OperationalIntent, if one is defined - var previousSubscription *scdmodels.Subscription - if old.SubscriptionID != nil { - previousSubscription, err = repo.GetSubscription(ctx, *old.SubscriptionID) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to get OperationalIntent's Subscription from repo") - } - if previousSubscription == nil { - return nil, stacktrace.NewError("OperationalIntent's Subscription missing from repo") - } - } - - removeImplicitSubscription, err := SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx, repo, id, previousSubscription) - if err != nil { - return nil, stacktrace.Propagate(err, "Could not determine if Subscription can be removed") - } - - // Gather the subscriptions that need to be notified - notifyVolume := &dssmodels.Volume4D{ - StartTime: old.StartTime, - EndTime: old.EndTime, - SpatialVolume: &dssmodels.Volume3D{ - AltitudeHi: old.AltitudeUpper, - AltitudeLo: old.AltitudeLower, - Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { - return old.Cells, nil - }), - }} - - subsToNotify, err := repo.IncrementNotificationIndicesForOperationalIntents(ctx, notifyVolume) - if err != nil { - return nil, stacktrace.Propagate(err, "could not obtain relevant subscriptions") - } - - // Delete OperationalIntent from repo - if err := repo.DeleteOperationalIntent(ctx, id); err != nil { - return nil, stacktrace.Propagate(err, "Unable to delete OperationalIntent from repo") - } - - // removeImplicitSubscription is only true if the OIR had a subscription defined - if removeImplicitSubscription { - // Automatically remove a now-unused implicit Subscription - err = repo.DeleteSubscription(ctx, previousSubscription.ID) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to delete associated implicit Subscription") - } - } - - // Return response to client - return &restapi.ChangeOperationalIntentReferenceResponse{ - OperationalIntentReference: *old.ToRest(), - Subscribers: makeSubscribersToNotify(subsToNotify), - }, nil -} - -func ExecuteGetOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { - req, ok := request.(*restapi.GetOperationalIntentReferenceRequest) - if !ok { - return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.GetOperationalIntentReferenceOperationID) - } - - id, err := dssmodels.IDFromString(string(req.Entityid)) - if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format: `%s`", req.Entityid) - } - - op, err := repo.GetOperationalIntent(ctx, id) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to get OperationalIntent from repo") - } - if op == nil { - return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent %s not found", id) - } - - if op.Manager != dssmodels.Manager(*req.Auth.ClientID) { - op.OVN = scdmodels.NoOvnPhrase - } - - return &restapi.GetOperationalIntentReferenceResponse{ - OperationalIntentReference: *op.ToRest(), - }, nil -} - -func ExecuteQueryOperationalIntentReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { - req, ok := request.(*restapi.QueryOperationalIntentReferencesRequest) - if !ok { - return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.QueryOperationalIntentReferencesOperationID) - } - - // Retrieve the area of interest parameter - aoi := req.Body.AreaOfInterest - if aoi == nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing area_of_interest") - } - - // Parse area of interest to common Volume4D - vol4, err := scdmodels.Volume4DFromSCDRest(aoi) - if err != nil { - return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Error parsing geometry") - } - - // Perform search query on Store - ops, err := repo.SearchOperationalIntents(ctx, vol4) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to query for OperationalIntents in repo") - } - - // Create response for client - response := &restapi.QueryOperationalIntentReferenceResponse{ - OperationalIntentReferences: make([]restapi.OperationalIntentReference, 0, len(ops)), - } - for _, op := range ops { - p := op.ToRest() - if op.Manager != dssmodels.Manager(*req.Auth.ClientID) { - noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) - p.Ovn = &noOvnPhrase - } - response.OperationalIntentReferences = append(response.OperationalIntentReferences, *p) - } - - return response, nil -} diff --git a/pkg/scd/constraints_handler.go b/pkg/scd/constraints_handler.go index 055502e72..692a5405c 100644 --- a/pkg/scd/constraints_handler.go +++ b/pkg/scd/constraints_handler.go @@ -7,8 +7,8 @@ import ( restapi "github.com/interuss/dss/pkg/api/scdv1" dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" - "github.com/interuss/dss/pkg/scd/actions" scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/scd/operations" "github.com/interuss/dss/pkg/scd/repos" dssstore "github.com/interuss/dss/pkg/store" "github.com/interuss/dss/pkg/timestamp" @@ -168,7 +168,7 @@ func (a *Server) UpdateConstraintReference(ctx context.Context, req *restapi.Upd // validateConstraintUpsertRequest performs the request validation that can be done ahead of the transaction. // Note that this does NOT check for anything related to access controls: any error returned should be labeled as a dsserr.BadRequest. func validateConstraintUpsertRequest(ctx context.Context, entityid restapi.EntityID, params *restapi.PutConstraintReferenceParameters, allowHTTPBaseUrls bool) error { - _, err := actions.ValidateAndReturnConstraintUpsertParams(timestamp.MustGetRequestTimestamp(ctx), entityid, params) + _, err := operations.ValidateAndReturnConstraintUpsertParams(timestamp.MustFromContext(ctx), entityid, params) if err != nil { return err } diff --git a/pkg/scd/operational_intents_handler.go b/pkg/scd/operational_intents_handler.go index 74df2bc04..4e56b920e 100644 --- a/pkg/scd/operational_intents_handler.go +++ b/pkg/scd/operational_intents_handler.go @@ -2,19 +2,16 @@ package scd import ( "context" - "time" - "github.com/golang/geo/s2" "github.com/interuss/dss/pkg/api" restapi "github.com/interuss/dss/pkg/api/scdv1" - "github.com/interuss/dss/pkg/auth" dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" - "github.com/interuss/dss/pkg/random" - "github.com/interuss/dss/pkg/scd/actions" scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/scd/operations" "github.com/interuss/dss/pkg/scd/repos" dssstore "github.com/interuss/dss/pkg/store" + "github.com/interuss/dss/pkg/timestamp" "github.com/interuss/stacktrace" ) @@ -141,8 +138,12 @@ func (a *Server) CreateOperationalIntentReference(ctx context.Context, req *rest return restapi.CreateOperationalIntentReferenceResponseSet{Response400: &restapi.ErrorResponse{ Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}} } + if _, err := operations.ValidateAndReturnOIRUpsertParams(timestamp.MustFromContext(ctx), req.Entityid, "", req.Body, a.AllowHTTPBaseUrls); err != nil { + return restapi.CreateOperationalIntentReferenceResponseSet{Response400: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters"))}} + } - respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, "", req.Body) + result, err := dssstore.TransactWithResult[repos.Repository, *operations.PutOperationalIntentReferenceResult](ctx, a.Store, req) if err != nil { err = stacktrace.Propagate(err, "Could not put Operational Intent Reference") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} @@ -155,14 +156,14 @@ func (a *Server) CreateOperationalIntentReference(ctx context.Context, req *rest return restapi.CreateOperationalIntentReferenceResponseSet{Response409: &restapi.AirspaceConflictResponse{ Message: dsserr.Handle(ctx, err)}} case dsserr.MissingOVNs: - return restapi.CreateOperationalIntentReferenceResponseSet{Response409: respConflict} + return restapi.CreateOperationalIntentReferenceResponseSet{Response409: result.Conflict} default: return restapi.CreateOperationalIntentReferenceResponseSet{Response500: &api.InternalServerErrorBody{ ErrorMessage: *dsserr.Handle(ctx, stacktrace.Propagate(err, "Got an unexpected error"))}} } } - return restapi.CreateOperationalIntentReferenceResponseSet{Response201: respOK} + return restapi.CreateOperationalIntentReferenceResponseSet{Response201: result.Response} } func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *restapi.UpdateOperationalIntentReferenceRequest, @@ -172,10 +173,14 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest return restapi.UpdateOperationalIntentReferenceResponseSet{Response400: &restapi.ErrorResponse{ Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}} } + if _, err := operations.ValidateAndReturnOIRUpsertParams(timestamp.MustFromContext(ctx), req.Entityid, req.Ovn, req.Body, a.AllowHTTPBaseUrls); err != nil { + return restapi.UpdateOperationalIntentReferenceResponseSet{Response400: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters"))}} + } - respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, req.Ovn, req.Body) + result, err := dssstore.TransactWithResult[repos.Repository, *operations.PutOperationalIntentReferenceResult](ctx, a.Store, req) if err != nil { - err = stacktrace.Propagate(err, "Could not put subscription") + err = stacktrace.Propagate(err, "Could not put Operational Intent Reference") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} switch stacktrace.GetCode(err) { case dsserr.PermissionDenied: @@ -186,581 +191,12 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: &restapi.AirspaceConflictResponse{ Message: dsserr.Handle(ctx, err)}} case dsserr.MissingOVNs: - return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: respConflict} + return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: result.Conflict} default: return restapi.UpdateOperationalIntentReferenceResponseSet{Response500: &api.InternalServerErrorBody{ ErrorMessage: *dsserr.Handle(ctx, stacktrace.Propagate(err, "Got an unexpected error"))}} } } - return restapi.UpdateOperationalIntentReferenceResponseSet{Response200: respOK} -} - -type validOIRParams struct { - id dssmodels.ID - ovn scdmodels.OVN - newOVN scdmodels.OVN - state scdmodels.OperationalIntentState - uExtent *dssmodels.Volume4D - cells s2.CellUnion - subscriptionID dssmodels.ID - ussBaseURL string - implicitSubscription struct { - requested bool - baseURL string - forConstraints bool - } - key map[scdmodels.OVN]bool -} - -func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodels.Subscription, version scdmodels.VersionNumber, pastOVNs []scdmodels.OVN) *scdmodels.OperationalIntent { - // For OIR's in the accepted state, we may not have a attachedSub available, - // in such cases the attachedSub ID on scdmodels.OperationalIntent will be nil - // and will be replaced with the 'NullV4UUID' when sent over to a client. - var subID *dssmodels.ID - if attachedSub != nil { - // Note: do _not_ use vp.subscriptionID here, as it may be empty - subID = &attachedSub.ID - } - return &scdmodels.OperationalIntent{ - ID: vp.id, - Manager: manager, - Version: version, - OVN: vp.newOVN, // non-empty only if the USS has requested an OVN - PastOVNs: pastOVNs, - - StartTime: vp.uExtent.StartTime, - EndTime: vp.uExtent.EndTime, - AltitudeLower: vp.uExtent.SpatialVolume.AltitudeLo, - AltitudeUpper: vp.uExtent.SpatialVolume.AltitudeHi, - Cells: vp.cells, - - USSBaseURL: vp.ussBaseURL, - SubscriptionID: subID, - State: vp.state, - } -} - -// validateAndReturnOIRUpsertParams checks that the parameters for an Operational Intent Reference upsert are valid. -// Note that this does NOT check for anything related to access controls: any error returned should be labeled -// as a dsserr.BadRequest. -func validateAndReturnOIRUpsertParams( - now time.Time, - entityid restapi.EntityID, - ovn restapi.EntityOVN, - params *restapi.PutOperationalIntentReferenceParameters, - allowHTTPBaseUrls bool, -) (*validOIRParams, error) { - - valid := &validOIRParams{} - var err error - - valid.id, err = dssmodels.IDFromString(string(entityid)) - if err != nil { - return nil, stacktrace.NewError("Invalid ID format: `%s`", entityid) - } - - if len(params.UssBaseUrl) == 0 { - return nil, stacktrace.NewError("Missing required UssBaseUrl") - } - - valid.ussBaseURL = string(params.UssBaseUrl) - - if params.SubscriptionId != nil { - valid.subscriptionID, err = dssmodels.IDFromOptionalString(string(*params.SubscriptionId)) - if err != nil { - return nil, stacktrace.NewError("Invalid ID format for Subscription ID: `%s`", *params.SubscriptionId) - } - } - - if params.NewSubscription != nil { - // The spec states that NewSubscription.UssBaseUrl is required and an empty value - // makes no sense, so we will fail if an implicit subscription is requested but the base url is empty - if params.NewSubscription.UssBaseUrl == "" { - return nil, stacktrace.NewError("Missing required USS base url for new subscription (in parameters for implicit subscription)") - } - // If an implicit subscription is requested, the Subscription ID cannot be present. - if params.SubscriptionId != nil { - return nil, stacktrace.NewError("Cannot provide both a Subscription ID and request an implicit subscription") - } - valid.implicitSubscription.requested = true - valid.implicitSubscription.baseURL = string(params.NewSubscription.UssBaseUrl) - // notify for constraints defaults to false if not specified - if params.NewSubscription.NotifyForConstraints != nil { - valid.implicitSubscription.forConstraints = *params.NewSubscription.NotifyForConstraints - } - } - - if !allowHTTPBaseUrls { - err = scdmodels.ValidateUSSBaseURL(string(params.UssBaseUrl)) - if err != nil { - return nil, stacktrace.Propagate(err, "Failed to validate base URL") - } - - if params.NewSubscription != nil { - err := scdmodels.ValidateUSSBaseURL(valid.implicitSubscription.baseURL) - if err != nil { - return nil, stacktrace.Propagate(err, "Failed to validate USS base URL for subscription (in parameters for implicit subscription)") - } - } - } - - valid.state = scdmodels.OperationalIntentState(params.State) - if !valid.state.IsValidInDSS() { - return nil, stacktrace.NewError("Invalid OperationalIntent state: %s", params.State) - } - - // Start and end times, as well as lower and upper altitudes, are required for each volume - // The end time may not be in the past. - valid.uExtent, err = scdmodels.UnionVolumes4DFromSCDRest( - params.Extents, - scdmodels.WithRequireTimeBounds(), - scdmodels.WithRequireAltitudeBounds(), - scdmodels.WithRequireEndTimeAfter(now), - ) - if err != nil { - return nil, stacktrace.Propagate(err, "Invalid extents") - } - valid.cells, err = valid.uExtent.CalculateSpatialCovering() - if err != nil { - return nil, stacktrace.Propagate(err, "Invalid area") - } - - if ovn == "" && params.State != restapi.OperationalIntentState_Accepted { - return nil, stacktrace.NewError("Invalid state for initial version: `%s`", params.State) - } - valid.ovn = scdmodels.OVN(ovn) - - if params.RequestedOvnSuffix != nil { - valid.newOVN, err = scdmodels.NewOVNFromUUIDv7Suffix(now, valid.id, string(*params.RequestedOvnSuffix)) - if err != nil { - return nil, stacktrace.Propagate(err, "Invalid requested OVN suffix") - } - } - - // Check if a subscription is required for this request: - // OIRs in an accepted state do not need a subscription. - if valid.state.RequiresSubscription() && - valid.subscriptionID.Empty() && - (params.NewSubscription == nil || - params.NewSubscription.UssBaseUrl == "") { - return nil, stacktrace.NewError("Provided Operational Intent Reference state `%s` requires either a subscription ID or information to create an implicit subscription", valid.state) - } - - // Construct a hash set of OVNs as the key - valid.key = map[scdmodels.OVN]bool{} - if params.Key != nil { - for _, ovn := range *params.Key { - valid.key[scdmodels.OVN(ovn)] = true - } - } - - return valid, nil -} - -// checkUpsertPermissions verifies that the client has the necessary permissions to upsert an Operational Intent with the requested state. -func checkUpsertPermissionsAndReturnManager(authorizedManager *api.AuthorizationResult, requestedState scdmodels.OperationalIntentState) (dssmodels.Manager, error) { - if authorizedManager.ClientID == nil { - return "", stacktrace.NewError("Missing manager") - } - hasCMSARole := auth.HasScope(authorizedManager.Scopes, restapi.UtmConformanceMonitoringSaScope) - if requestedState.RequiresCMSA() && !hasCMSARole { - return "", stacktrace.NewError("Missing `%s` Conformance Monitoring for Situational Awareness scope to transition to CMSA state: %s (see SCD0100)", restapi.UtmConformanceMonitoringSaScope, requestedState) - } - return dssmodels.Manager(*authorizedManager.ClientID), nil -} - -// validateUpsertRequestAgainstPreviousOIR checks that the client requesting an OIR upsert has the necessary permissions and that the request is valid. -// On success, the version of the OIR is returned: -// - upon initial creation (if no previous OIR exists), it is 0 -// - otherwise, it is the version of the previous OIR -func validateUpsertRequestAgainstPreviousOIR( - requestingManager dssmodels.Manager, - providedOVN scdmodels.OVN, - previousOIR *scdmodels.OperationalIntent, -) error { - - if previousOIR != nil { - if previousOIR.Manager != requestingManager { - return stacktrace.NewErrorWithCode(dsserr.PermissionDenied, - "OperationalIntent owned by %s, but %s attempted to modify", previousOIR.Manager, requestingManager) - } - if previousOIR.OVN != providedOVN { - return stacktrace.NewErrorWithCode(dsserr.VersionMismatch, - "Current version is %s but client specified version %s", previousOIR.OVN, providedOVN) - } - - return nil - } - - if providedOVN != "" { - return stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent does not exist and therefore is not version %s", providedOVN) - } - - return nil -} - -// createAndStoreNewImplicitSubscription will create a brand new implicit subscription based on the provided parameters, -// store it and return it. -func createAndStoreNewImplicitSubscription(ctx context.Context, r repos.Repository, manager dssmodels.Manager, validParams *validOIRParams) (*scdmodels.Subscription, error) { - generator, err := random.Generator(random.MustFromContext(ctx), "implicit-subscription:"+validParams.id.String()) - if err != nil { - return nil, stacktrace.Propagate(err, "Failed to derive implicit subscription ID generator") - } - id, err := scdmodels.NewDeterministicImplicitSubscriptionID(generator) - if err != nil { - return nil, stacktrace.Propagate(err, "Failed to create implicit subscription ID") - } - - subToUpsert := scdmodels.Subscription{ - ID: id, - Manager: manager, - StartTime: validParams.uExtent.StartTime, - EndTime: validParams.uExtent.EndTime, - AltitudeLo: validParams.uExtent.SpatialVolume.AltitudeLo, - AltitudeHi: validParams.uExtent.SpatialVolume.AltitudeHi, - Cells: validParams.cells, - USSBaseURL: validParams.implicitSubscription.baseURL, - NotifyForOperationalIntents: true, - NotifyForConstraints: validParams.implicitSubscription.forConstraints, - ImplicitSubscription: true, - } - - return r.UpsertSubscription(ctx, &subToUpsert) -} - -// computeNotificationVolume computes the volume that needs to be queried for subscriptions -// given the requested extent and the (possibly nil) previous operational intent. -// The returned volume is either the union of the requested extent and the previous OIR's extent, or just the requested extent -// if the previous OIR is nil. -func computeNotificationVolume( - previousOIR *scdmodels.OperationalIntent, - requestedExtent *dssmodels.Volume4D) (*dssmodels.Volume4D, error) { - - if previousOIR == nil { - return requestedExtent, nil - } - - // Compute total affected Volume4D for notification purposes - oldVolume := &dssmodels.Volume4D{ - StartTime: previousOIR.StartTime, - EndTime: previousOIR.EndTime, - SpatialVolume: &dssmodels.Volume3D{ - AltitudeHi: previousOIR.AltitudeUpper, - AltitudeLo: previousOIR.AltitudeLower, - Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { - return previousOIR.Cells, nil - }), - }, - } - notifyVolume, err := dssmodels.UnionVolumes4D(requestedExtent, oldVolume) - if err != nil { - return nil, stacktrace.Propagate(err, "Error constructing 4D volumes union") - } - - return notifyVolume, nil -} - -// validateKeyAndProvideConflictResponse ensures that the provided key contains all the necessary OVNs relevant for the area covered by the OperationalIntent. -// - If all required keys are provided, (nil, nil) will be returned. -// - If keys are missing, the conflict response to be sent back as well as an error with the dsserr.MissingOVNs code will be returned. -// - In case of any other error, (nil, error) will be returned. -func validateKeyAndProvideConflictResponse( - ctx context.Context, - r repos.Repository, - requestingManager dssmodels.Manager, - params *validOIRParams, - attachedSubscription *scdmodels.Subscription, -) (*restapi.AirspaceConflictResponse, error) { - - // Identify OperationalIntents missing from the key - var missingOps []*scdmodels.OperationalIntent - relevantOps, err := r.SearchOperationalIntents(ctx, params.uExtent) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to SearchOperations") - } - for _, relevantOp := range relevantOps { - _, ok := params.key[relevantOp.OVN] - // Note: The OIR being mutated does not need to be specified in the key: - if !ok && relevantOp.RequiresKey() && relevantOp.ID != params.id { - missingOps = append(missingOps, relevantOp) - } - } - - // Identify Constraints missing from the key - var missingConstraints []*scdmodels.Constraint - if attachedSubscription != nil && attachedSubscription.NotifyForConstraints { - constraints, err := r.SearchConstraints(ctx, params.uExtent) - if err != nil { - return nil, stacktrace.Propagate(err, "Unable to SearchConstraints") - } - for _, relevantConstraint := range constraints { - if _, ok := params.key[relevantConstraint.OVN]; !ok { - missingConstraints = append(missingConstraints, relevantConstraint) - } - } - } - - // If the client is missing some OVNs, provide the pointers to the - // information they need - if len(missingOps) > 0 || len(missingConstraints) > 0 { - msg := "Current OVNs not provided for one or more OperationalIntents or Constraints" - responseConflict := &restapi.AirspaceConflictResponse{Message: &msg} - - if len(missingOps) > 0 { - responseConflict.MissingOperationalIntents = new([]restapi.OperationalIntentReference) - for _, missingOp := range missingOps { - p := missingOp.ToRest() - // We scrub the OVNs of entities not owned by the requesting manager to make sure - // they have really contacted the managing USS - if missingOp.Manager != requestingManager { - noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) - p.Ovn = &noOvnPhrase - } - *responseConflict.MissingOperationalIntents = append(*responseConflict.MissingOperationalIntents, *p) - } - } - - if len(missingConstraints) > 0 { - responseConflict.MissingConstraints = new([]restapi.ConstraintReference) - for _, missingConstraint := range missingConstraints { - c := missingConstraint.ToRest() - // We scrub the OVNs of entities not owned by the requesting manager to make sure - // they have really contacted the managing USS - if missingConstraint.Manager != requestingManager { - noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) - c.Ovn = &noOvnPhrase - } - *responseConflict.MissingConstraints = append(*responseConflict.MissingConstraints, *c) - } - } - - return responseConflict, stacktrace.NewErrorWithCode(dsserr.MissingOVNs, "Missing OVNs: %v", msg) - } - - return nil, nil -} - -// ensureSubscriptionCoversOIR ensures that the subscription covers the requested geo-temporal extent, extending it if both possible and required, -// or failing otherwise. -// After this method returns successfully, the subscription will cover the requested geo-temporal extent. -func ensureSubscriptionCoversOIR(ctx context.Context, r repos.Repository, sub *scdmodels.Subscription, params *validOIRParams) (*scdmodels.Subscription, error) { - - updateSub := false - if sub.StartTime != nil && sub.StartTime.After(*params.uExtent.StartTime) { - if sub.ImplicitSubscription { - sub.StartTime = params.uExtent.StartTime - updateSub = true - } else { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription does not begin until after the OperationalIntent starts") - } - } - if sub.EndTime != nil && sub.EndTime.Before(*params.uExtent.EndTime) { - if sub.ImplicitSubscription { - sub.EndTime = params.uExtent.EndTime - updateSub = true - } else { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription ends before the OperationalIntent ends") - } - } - if !sub.Cells.Contains(params.cells) { - if sub.ImplicitSubscription { - sub.Cells = s2.CellUnionFromUnion(sub.Cells, params.cells) - updateSub = true - } else { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription does not cover entire spatial area of the OperationalIntent") - } - } - if updateSub { - upsertedSub, err := r.UpsertSubscription(ctx, sub) - if err != nil { - return nil, stacktrace.Propagate(err, "Failed to update existing Subscription") - } - return upsertedSub, nil - } - - return sub, nil -} - -// upsertOperationalIntentReference inserts or updates an Operational Intent. -// If the ovn argument is empty (""), it will attempt to create a new Operational Intent. -func (a *Server) upsertOperationalIntentReference(ctx context.Context, now time.Time, authorizedManager *api.AuthorizationResult, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters, -) (*restapi.ChangeOperationalIntentReferenceResponse, *restapi.AirspaceConflictResponse, error) { - // Note: validateAndReturnOIRUpsertParams and checkUpsertPermissionsAndReturnManager could be moved out of this method and only the valid params passed, - // but this requires some changes in the caller that go beyond the immediate scope of #1088 and can be done later. - validParams, err := validateAndReturnOIRUpsertParams(now, entityid, ovn, params, a.AllowHTTPBaseUrls) - if err != nil { - return nil, nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters") - } - manager, err := checkUpsertPermissionsAndReturnManager(authorizedManager, validParams.state) - if err != nil { - return nil, nil, stacktrace.PropagateWithCode(err, dsserr.PermissionDenied, "Caller is not allowed to upsert with the requested state") - } - - var responseOK *restapi.ChangeOperationalIntentReferenceResponse - var responseConflict *restapi.AirspaceConflictResponse - action := func(ctx context.Context, r repos.Repository) (err error) { - - // Get existing OperationalIntent, if any - old, err := r.GetOperationalIntent(ctx, validParams.id) - if err != nil { - return stacktrace.Propagate(err, "Could not get OperationalIntent from repo") - } - - // Lock subscriptions based on the cell and subscriptions we're going to use - // to reduce the number of retries under concurrent load. - // See issue #1002 for details. - var subscriptionIds = make([]dssmodels.ID, 0) - - if old != nil && old.SubscriptionID != nil { - subscriptionIds = append(subscriptionIds, *old.SubscriptionID) - } - - if !validParams.subscriptionID.Empty() { - subscriptionIds = append(subscriptionIds, validParams.subscriptionID) - } - - err = r.LockSubscriptionsOnCells(ctx, validParams.cells, subscriptionIds, validParams.uExtent.StartTime, validParams.uExtent.EndTime) - if err != nil { - return stacktrace.Propagate(err, "Unable to acquire lock") - } - - // Validate the request against the previous OIR - if err := validateUpsertRequestAgainstPreviousOIR(manager, validParams.ovn, old); err != nil { - return stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "Request validation failed") - } - - var ( - version = scdmodels.VersionNumber(1) - pastOVNs = make([]scdmodels.OVN, 0) - previousSub *scdmodels.Subscription - ) - if old != nil { - version = old.Version + 1 - pastOVNs = append(old.PastOVNs, validParams.ovn) - - // Fetch the previous OIR's subscription if it exists - if old.SubscriptionID != nil { - previousSub, err = r.GetSubscription(ctx, *old.SubscriptionID) - if err != nil { - return stacktrace.Propagate(err, "Unable to get OperationalIntent's Subscription from repo") - } - } - } - - // Determine if the previous subscription is being replaced and if it will need to be cleaned up - previousSubIsBeingReplaced := previousSub != nil && validParams.subscriptionID != previousSub.ID - removePreviousImplicitSubscription := false - if previousSubIsBeingReplaced { - removePreviousImplicitSubscription, err = actions.SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx, r, validParams.id, previousSub) - if err != nil { - return stacktrace.Propagate(err, "Could not determine if previous Subscription can be removed") - } - } - - // attachedSub is the subscription that will end up being attached to the OIR - // it defaults to the previous subscription (which may be nil), and may be updated if required by the parameters - attachedSub := previousSub - if validParams.subscriptionID.Empty() { - // No subscription ID was provided: - // check if an implicit subscription should be created, otherwise do nothing - if validParams.implicitSubscription.requested { - // Parameters for a new implicit subscription have been passed: we will create - // a new implicit subscription even if another subscription was attached to this OIR before, - // regardless of whether it was an implicit subscription or not. - if attachedSub, err = createAndStoreNewImplicitSubscription(ctx, r, manager, validParams); err != nil { - return stacktrace.Propagate(err, "Failed to create implicit subscription") - } - } else { - // If no subscription ID is provided and no implicit subscription is requested, - // the OIR should have no attached subscription - attachedSub = nil - } - } else { - // Attempt to rely on the specified subscription - // If it is different from the previous subscription, we need to fetch it from the store - // in order to ensure it correctly covers the OIR. - // We do the check below in order to avoid re-fetching the subscription if it has not changed - if attachedSub == nil || previousSubIsBeingReplaced { - attachedSub, err = r.GetSubscription(ctx, validParams.subscriptionID) - if err != nil { - return stacktrace.Propagate(err, "Unable to get requested Subscription from store") - } - if attachedSub == nil { - return stacktrace.NewErrorWithCode(dsserr.BadRequest, "Specified Subscription %s does not exist", validParams.subscriptionID) - } - } - - // We need to confirm that it is owned by the calling manager - if attachedSub.Manager != manager { - return stacktrace.Propagate( - // We do a bit of wrapping gymnastics because the root error message will be sent in the response, - // and we don't want to include the effective manager in there. - stacktrace.NewErrorWithCode( - dsserr.PermissionDenied, "Specificed Subscription is owned by different client"), - // The propagation message will end in the logs and help with debugging. - "Subscription %s owned by %s, but %s attempted to use it for an OperationalIntent", - validParams.subscriptionID, - attachedSub.Manager, - manager, - ) - } - - // We need to ensure the subscription covers the OIR's geo-temporal extent - attachedSub, err = ensureSubscriptionCoversOIR(ctx, r, attachedSub, validParams) - if err != nil { - return stacktrace.Propagate(err, "Failed to ensure subscription covers OIR") - } - } - - if validParams.state.RequiresKey() { - responseConflict, err = validateKeyAndProvideConflictResponse(ctx, r, manager, validParams, attachedSub) - if err != nil { - return stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "Failed to validate key") - } - } - - // Construct the new OperationalIntent - op := validParams.toOIR(manager, attachedSub, version, pastOVNs) - - // Upsert the OperationalIntent - op, err = r.UpsertOperationalIntent(ctx, op) - if err != nil { - return stacktrace.Propagate(err, "Failed to upsert OperationalIntent in repo") - } - - // Check if the previously attached subscription should be removed - if removePreviousImplicitSubscription { - err = r.DeleteSubscription(ctx, previousSub.ID) - if err != nil { - return stacktrace.Propagate(err, "Unable to delete previous implicit Subscription") - } - } - - notifyVolume, err := computeNotificationVolume(old, validParams.uExtent) - if err != nil { - return stacktrace.Propagate(err, "Failed to compute notification volume") - } - - // Notify relevant Subscriptions - subsToNotify, err := r.IncrementNotificationIndicesForOperationalIntents(ctx, notifyVolume) - if err != nil { - return stacktrace.Propagate(err, "Failed to notify relevant Subscriptions") - } - - // Return response to client - responseOK = &restapi.ChangeOperationalIntentReferenceResponse{ - OperationalIntentReference: *op.ToRest(), - Subscribers: makeSubscribersToNotify(subsToNotify), - } - - return nil - } - - _, err = a.Store.Transact(ctx, dssstore.NewFuncOperation(action)) - if err != nil { - return nil, responseConflict, err // No need to Propagate this error as this is not a useful stacktrace line - } - - return responseOK, responseConflict, nil + return restapi.UpdateOperationalIntentReferenceResponseSet{Response200: result.Response} } diff --git a/pkg/scd/actions/availability.go b/pkg/scd/operations/availability.go similarity index 94% rename from pkg/scd/actions/availability.go rename to pkg/scd/operations/availability.go index 0581ddf3e..4678b90d0 100644 --- a/pkg/scd/actions/availability.go +++ b/pkg/scd/operations/availability.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "context" @@ -17,17 +17,17 @@ func init() { Registry[restapi.GetUssAvailabilityOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.GetUssAvailabilityRequest], - Execute: ExecuteGetUssAvailability, + Execute: executeGetUssAvailability, IsReadOnly: true, } Registry[restapi.SetUssAvailabilityOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.SetUssAvailabilityRequest], - Execute: ExecuteSetUssAvailability, + Execute: executeSetUssAvailability, } } -func ExecuteGetUssAvailability(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeGetUssAvailability(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.GetUssAvailabilityRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.GetUssAvailabilityOperationID) @@ -55,7 +55,7 @@ func ExecuteGetUssAvailability(ctx context.Context, repo repos.Repository, reque }, nil } -func ExecuteSetUssAvailability(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeSetUssAvailability(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.SetUssAvailabilityRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.SetUssAvailabilityOperationID) diff --git a/pkg/scd/actions/constraint.go b/pkg/scd/operations/constraint.go similarity index 95% rename from pkg/scd/actions/constraint.go rename to pkg/scd/operations/constraint.go index 262be05f1..7b32dd7f5 100644 --- a/pkg/scd/actions/constraint.go +++ b/pkg/scd/operations/constraint.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "context" @@ -20,33 +20,33 @@ func init() { Registry[restapi.DeleteConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.DeleteConstraintReferenceRequest], - Execute: ExecuteDeleteConstraint, + Execute: executeDeleteConstraint, } Registry[restapi.GetConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.GetConstraintReferenceRequest], - Execute: ExecuteGetConstraint, + Execute: executeGetConstraint, IsReadOnly: true, } Registry[restapi.CreateConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.CreateConstraintReferenceRequest], - Execute: ExecutePutConstraint, + Execute: executePutConstraint, } Registry[restapi.UpdateConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.UpdateConstraintReferenceRequest], - Execute: ExecutePutConstraint, + Execute: executePutConstraint, } Registry[restapi.QueryConstraintReferencesOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.QueryConstraintReferencesRequest], - Execute: ExecuteQueryConstraintReferences, + Execute: executeQueryConstraintReferences, IsReadOnly: true, } } -func ExecuteGetConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeGetConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.GetConstraintReferenceRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.GetConstraintReferenceOperationID) @@ -75,9 +75,9 @@ func ExecuteGetConstraint(ctx context.Context, repo repos.Repository, request ds }, nil } -// ExecutePutConstraint inserts or updates a Constraint. +// executePutConstraint inserts or updates a Constraint. // If ovn is empty (""), it will attempt to create a new Constraint. -func ExecutePutConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executePutConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { var ( manager string entityid restapi.EntityID @@ -94,7 +94,7 @@ func ExecutePutConstraint(ctx context.Context, repo repos.Repository, request ds return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.CreateConstraintReferenceOperationID) } - validParams, err := ValidateAndReturnConstraintUpsertParams(timestamp.MustGetRequestTimestamp(ctx), entityid, params) + validParams, err := ValidateAndReturnConstraintUpsertParams(timestamp.MustFromContext(ctx), entityid, params) if err != nil { return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Constraint upsert parameters") } @@ -230,7 +230,7 @@ func ValidateAndReturnConstraintUpsertParams( return valid, nil } -func ExecuteQueryConstraintReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeQueryConstraintReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.QueryConstraintReferencesRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.QueryConstraintReferencesOperationID) @@ -270,7 +270,7 @@ func ExecuteQueryConstraintReferences(ctx context.Context, repo repos.Repository return response, nil } -func ExecuteDeleteConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeDeleteConstraint(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.DeleteConstraintReferenceRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.DeleteConstraintReferenceOperationID) diff --git a/pkg/scd/operations/operational_intents.go b/pkg/scd/operations/operational_intents.go new file mode 100644 index 000000000..77085eba7 --- /dev/null +++ b/pkg/scd/operations/operational_intents.go @@ -0,0 +1,836 @@ +package operations + +import ( + "context" + "time" + + "github.com/golang/geo/s2" + "github.com/interuss/dss/pkg/api" + restapi "github.com/interuss/dss/pkg/api/scdv1" + "github.com/interuss/dss/pkg/auth" + dsserr "github.com/interuss/dss/pkg/errors" + dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/random" + scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/scd/repos" + dssstore "github.com/interuss/dss/pkg/store" + "github.com/interuss/dss/pkg/timestamp" + "github.com/interuss/stacktrace" +) + +func init() { + Registry[restapi.GetOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.GetOperationalIntentReferenceRequest], + Execute: executeGetOperationalIntentReference, + IsReadOnly: true, + } + Registry[restapi.QueryOperationalIntentReferencesOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.QueryOperationalIntentReferencesRequest], + Execute: executeQueryOperationalIntentReferences, + IsReadOnly: true, + } + Registry[restapi.DeleteOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.DeleteOperationalIntentReferenceRequest], + Execute: executeDeleteOperationalIntentReference, + } + Registry[restapi.CreateOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.CreateOperationalIntentReferenceRequest], + Execute: executePutOperationalIntentReference, + } + Registry[restapi.UpdateOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.UpdateOperationalIntentReferenceRequest], + Execute: executePutOperationalIntentReference, + } +} + +// SubscriptionIsImplicitAndOnlyAttachedToOIR will check if: +// - the subscription is defined and is implicit +// - the subscription is attached to the specified operational intent +// - the subscription is not attached to any other operational intent +// +// This is to be used in contexts where an implicit subscription may need to be cleaned up: if true is returned, +// the subscription can be safely removed after the operational intent is deleted or attached to another subscription. +// +// NOTE: this should eventually be pushed down the datastore as part of the queries being executed in the callers of this method. +// +// See https://github.com/interuss/dss/issues/1059 for more details +func SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx context.Context, r repos.Repository, oirID dssmodels.ID, subscription *scdmodels.Subscription) (bool, error) { + if subscription == nil { + return false, nil + } + if !subscription.ImplicitSubscription { + return false, nil + } + // Get the Subscription's dependent OperationalIntents + dependentOps, err := r.GetDependentOperationalIntents(ctx, subscription.ID) + if err != nil { + return false, stacktrace.Propagate(err, "Could not find dependent OperationalIntents") + } + if len(dependentOps) == 0 { + return false, stacktrace.NewError("An implicit Subscription had no dependent OperationalIntents") + } else if len(dependentOps) == 1 && dependentOps[0] == oirID { + return true, nil + } + return false, nil +} + +// executeDeleteOperationalIntentReference deletes a single operational intent ref for a given ID +// at the specified version. +func executeDeleteOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { + req, ok := request.(*restapi.DeleteOperationalIntentReferenceRequest) + if !ok { + return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.DeleteOperationalIntentReferenceOperationID) + } + + // Retrieve OperationalIntent ID + id, err := dssmodels.IDFromString(string(req.Entityid)) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format: `%s`", req.Entityid) + } + + // Get OperationalIntent to delete + old, err := repo.GetOperationalIntent(ctx, id) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to get OperationIntent from repo") + } + if old == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent %s not found", id) + } + + // Validate deletion request + if old.Manager != dssmodels.Manager(*req.Auth.ClientID) { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, + "OperationalIntent owned by %s, but %s attempted to delete", old.Manager, *req.Auth.ClientID) + } + + if old.OVN != scdmodels.OVN(req.Ovn) { + return nil, stacktrace.NewErrorWithCode(dsserr.VersionMismatch, + "Current version is %s but client specified version %s", old.OVN, scdmodels.OVN(req.Ovn)) + } + + // Lock subscriptions based on the cell and subscriptions we're going to use + // to reduce the number of retries under concurrent load. + // See issue #1002 for details. + var subscriptionIds = make([]dssmodels.ID, 0) + + if old.SubscriptionID != nil { + subscriptionIds = append(subscriptionIds, *old.SubscriptionID) + } + + err = repo.LockSubscriptionsOnCells(ctx, old.Cells, subscriptionIds, old.StartTime, old.EndTime) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to acquire lock") + } + + // Get the Subscription supporting the OperationalIntent, if one is defined + var previousSubscription *scdmodels.Subscription + if old.SubscriptionID != nil { + previousSubscription, err = repo.GetSubscription(ctx, *old.SubscriptionID) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to get OperationalIntent's Subscription from repo") + } + if previousSubscription == nil { + return nil, stacktrace.NewError("OperationalIntent's Subscription missing from repo") + } + } + + removeImplicitSubscription, err := SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx, repo, id, previousSubscription) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not determine if Subscription can be removed") + } + + // Gather the subscriptions that need to be notified + notifyVolume := &dssmodels.Volume4D{ + StartTime: old.StartTime, + EndTime: old.EndTime, + SpatialVolume: &dssmodels.Volume3D{ + AltitudeHi: old.AltitudeUpper, + AltitudeLo: old.AltitudeLower, + Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { + return old.Cells, nil + }), + }} + + subsToNotify, err := repo.IncrementNotificationIndicesForOperationalIntents(ctx, notifyVolume) + if err != nil { + return nil, stacktrace.Propagate(err, "could not obtain relevant subscriptions") + } + + // Delete OperationalIntent from repo + if err := repo.DeleteOperationalIntent(ctx, id); err != nil { + return nil, stacktrace.Propagate(err, "Unable to delete OperationalIntent from repo") + } + + // removeImplicitSubscription is only true if the OIR had a subscription defined + if removeImplicitSubscription { + // Automatically remove a now-unused implicit Subscription + err = repo.DeleteSubscription(ctx, previousSubscription.ID) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to delete associated implicit Subscription") + } + } + + // Return response to client + return &restapi.ChangeOperationalIntentReferenceResponse{ + OperationalIntentReference: *old.ToRest(), + Subscribers: makeSubscribersToNotify(subsToNotify), + }, nil +} + +func executeGetOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { + req, ok := request.(*restapi.GetOperationalIntentReferenceRequest) + if !ok { + return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.GetOperationalIntentReferenceOperationID) + } + + id, err := dssmodels.IDFromString(string(req.Entityid)) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format: `%s`", req.Entityid) + } + + op, err := repo.GetOperationalIntent(ctx, id) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to get OperationalIntent from repo") + } + if op == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent %s not found", id) + } + + if op.Manager != dssmodels.Manager(*req.Auth.ClientID) { + op.OVN = scdmodels.NoOvnPhrase + } + + return &restapi.GetOperationalIntentReferenceResponse{ + OperationalIntentReference: *op.ToRest(), + }, nil +} + +func executeQueryOperationalIntentReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { + req, ok := request.(*restapi.QueryOperationalIntentReferencesRequest) + if !ok { + return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.QueryOperationalIntentReferencesOperationID) + } + + // Retrieve the area of interest parameter + aoi := req.Body.AreaOfInterest + if aoi == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing area_of_interest") + } + + // Parse area of interest to common Volume4D + vol4, err := scdmodels.Volume4DFromSCDRest(aoi) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Error parsing geometry") + } + + // Perform search query on Store + ops, err := repo.SearchOperationalIntents(ctx, vol4) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to query for OperationalIntents in repo") + } + + // Create response for client + response := &restapi.QueryOperationalIntentReferenceResponse{ + OperationalIntentReferences: make([]restapi.OperationalIntentReference, 0, len(ops)), + } + for _, op := range ops { + p := op.ToRest() + if op.Manager != dssmodels.Manager(*req.Auth.ClientID) { + noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) + p.Ovn = &noOvnPhrase + } + response.OperationalIntentReferences = append(response.OperationalIntentReferences, *p) + } + + return response, nil +} + +// CheckUpsertPermissionsAndReturnManager verifies that the client has the necessary permissions to upsert an Operational Intent with the requested state. +func CheckUpsertPermissionsAndReturnManager(authorizedManager *api.AuthorizationResult, requestedState scdmodels.OperationalIntentState) (dssmodels.Manager, error) { + if authorizedManager.ClientID == nil { + return "", stacktrace.NewError("Missing manager") + } + hasCMSARole := auth.HasScope(authorizedManager.Scopes, restapi.UtmConformanceMonitoringSaScope) + if requestedState.RequiresCMSA() && !hasCMSARole { + return "", stacktrace.NewError("Missing `%s` Conformance Monitoring for Situational Awareness scope to transition to CMSA state: %s (see SCD0100)", restapi.UtmConformanceMonitoringSaScope, requestedState) + } + return dssmodels.Manager(*authorizedManager.ClientID), nil +} + +// validateUpsertRequestAgainstPreviousOIR checks that the client requesting an OIR upsert has the necessary permissions and that the request is valid. +// On success, the version of the OIR is returned: +// - upon initial creation (if no previous OIR exists), it is 0 +// - otherwise, it is the version of the previous OIR +func validateUpsertRequestAgainstPreviousOIR( + requestingManager dssmodels.Manager, + providedOVN scdmodels.OVN, + previousOIR *scdmodels.OperationalIntent, +) error { + + if previousOIR != nil { + if previousOIR.Manager != requestingManager { + return stacktrace.NewErrorWithCode(dsserr.PermissionDenied, + "OperationalIntent owned by %s, but %s attempted to modify", previousOIR.Manager, requestingManager) + } + if previousOIR.OVN != providedOVN { + return stacktrace.NewErrorWithCode(dsserr.VersionMismatch, + "Current version is %s but client specified version %s", previousOIR.OVN, providedOVN) + } + + return nil + } + + if providedOVN != "" { + return stacktrace.NewErrorWithCode(dsserr.NotFound, "OperationalIntent does not exist and therefore is not version %s", providedOVN) + } + + return nil +} + +// computeNotificationVolume computes the volume that needs to be queried for subscriptions +// given the requested extent and the (possibly nil) previous operational intent. +// The returned volume is either the union of the requested extent and the previous OIR's extent, or just the requested extent +// if the previous OIR is nil. +func computeNotificationVolume( + previousOIR *scdmodels.OperationalIntent, + requestedExtent *dssmodels.Volume4D) (*dssmodels.Volume4D, error) { + + if previousOIR == nil { + return requestedExtent, nil + } + + // Compute total affected Volume4D for notification purposes + oldVolume := &dssmodels.Volume4D{ + StartTime: previousOIR.StartTime, + EndTime: previousOIR.EndTime, + SpatialVolume: &dssmodels.Volume3D{ + AltitudeHi: previousOIR.AltitudeUpper, + AltitudeLo: previousOIR.AltitudeLower, + Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { + return previousOIR.Cells, nil + }), + }, + } + notifyVolume, err := dssmodels.UnionVolumes4D(requestedExtent, oldVolume) + if err != nil { + return nil, stacktrace.Propagate(err, "Error constructing 4D volumes union") + } + + return notifyVolume, nil +} + +type validOIRParams struct { + ID dssmodels.ID + OVN scdmodels.OVN + NewOVN scdmodels.OVN + State scdmodels.OperationalIntentState + UExtent *dssmodels.Volume4D + Cells s2.CellUnion + SubscriptionID dssmodels.ID + USSBaseURL string + ImplicitSubscription struct { + Requested bool + BaseURL string + ForConstraints bool + } + Key map[scdmodels.OVN]bool +} + +func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodels.Subscription, version scdmodels.VersionNumber, pastOVNs []scdmodels.OVN) *scdmodels.OperationalIntent { + // For OIR's in the accepted state, we may not have a attachedSub available, + // in such cases the attachedSub ID on scdmodels.OperationalIntent will be nil + // and will be replaced with the 'NullV4UUID' when sent over to a client. + var subID *dssmodels.ID + if attachedSub != nil { + // Note: do _not_ use vp.SubscriptionID here, as it may be empty + subID = &attachedSub.ID + } + return &scdmodels.OperationalIntent{ + ID: vp.ID, + Manager: manager, + Version: version, + OVN: vp.NewOVN, // non-empty only if the USS has requested an OVN + PastOVNs: pastOVNs, + + StartTime: vp.UExtent.StartTime, + EndTime: vp.UExtent.EndTime, + AltitudeLower: vp.UExtent.SpatialVolume.AltitudeLo, + AltitudeUpper: vp.UExtent.SpatialVolume.AltitudeHi, + Cells: vp.Cells, + + USSBaseURL: vp.USSBaseURL, + SubscriptionID: subID, + State: vp.State, + } +} + +// ValidateAndReturnOIRUpsertParams checks that the parameters for an Operational Intent Reference upsert are valid. +// Note that this does NOT check for anything related to access controls: any error returned should be labeled +// as a dsserr.BadRequest. +func ValidateAndReturnOIRUpsertParams( + now time.Time, + entityid restapi.EntityID, + ovn restapi.EntityOVN, + params *restapi.PutOperationalIntentReferenceParameters, + allowHTTPBaseUrls bool, +) (*validOIRParams, error) { + + valid := &validOIRParams{} + var err error + + valid.ID, err = dssmodels.IDFromString(string(entityid)) + if err != nil { + return nil, stacktrace.NewError("Invalid ID format: `%s`", entityid) + } + + if len(params.UssBaseUrl) == 0 { + return nil, stacktrace.NewError("Missing required UssBaseUrl") + } + + valid.USSBaseURL = string(params.UssBaseUrl) + + if params.SubscriptionId != nil { + valid.SubscriptionID, err = dssmodels.IDFromOptionalString(string(*params.SubscriptionId)) + if err != nil { + return nil, stacktrace.NewError("Invalid ID format for Subscription ID: `%s`", *params.SubscriptionId) + } + } + + if params.NewSubscription != nil { + // The spec states that NewSubscription.UssBaseUrl is required and an empty value + // makes no sense, so we will fail if an implicit subscription is requested but the base url is empty + if params.NewSubscription.UssBaseUrl == "" { + return nil, stacktrace.NewError("Missing required USS base url for new subscription (in parameters for implicit subscription)") + } + // If an implicit subscription is requested, the Subscription ID cannot be present. + if params.SubscriptionId != nil { + return nil, stacktrace.NewError("Cannot provide both a Subscription ID and request an implicit subscription") + } + valid.ImplicitSubscription.Requested = true + valid.ImplicitSubscription.BaseURL = string(params.NewSubscription.UssBaseUrl) + // notify for constraints defaults to false if not specified + if params.NewSubscription.NotifyForConstraints != nil { + valid.ImplicitSubscription.ForConstraints = *params.NewSubscription.NotifyForConstraints + } + } + + if !allowHTTPBaseUrls { + err = scdmodels.ValidateUSSBaseURL(string(params.UssBaseUrl)) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to validate base URL") + } + + if params.NewSubscription != nil { + err := scdmodels.ValidateUSSBaseURL(valid.ImplicitSubscription.BaseURL) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to validate USS base URL for subscription (in parameters for implicit subscription)") + } + } + } + + valid.State = scdmodels.OperationalIntentState(params.State) + if !valid.State.IsValidInDSS() { + return nil, stacktrace.NewError("Invalid OperationalIntent state: %s", params.State) + } + + // Start and end times, as well as lower and upper altitudes, are required for each volume + // The end time may not be in the past. + valid.UExtent, err = scdmodels.UnionVolumes4DFromSCDRest( + params.Extents, + scdmodels.WithRequireTimeBounds(), + scdmodels.WithRequireAltitudeBounds(), + scdmodels.WithRequireEndTimeAfter(now), + ) + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid extents") + } + valid.Cells, err = valid.UExtent.CalculateSpatialCovering() + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid area") + } + + if ovn == "" && params.State != restapi.OperationalIntentState_Accepted { + return nil, stacktrace.NewError("Invalid state for initial version: `%s`", params.State) + } + valid.OVN = scdmodels.OVN(ovn) + + if params.RequestedOvnSuffix != nil { + valid.NewOVN, err = scdmodels.NewOVNFromUUIDv7Suffix(now, valid.ID, string(*params.RequestedOvnSuffix)) + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid requested OVN suffix") + } + } + + // Check if a subscription is required for this request: + // OIRs in an accepted state do not need a subscription. + if valid.State.RequiresSubscription() && + valid.SubscriptionID.Empty() && + (params.NewSubscription == nil || + params.NewSubscription.UssBaseUrl == "") { + return nil, stacktrace.NewError("Provided Operational Intent Reference state `%s` requires either a subscription ID or information to create an implicit subscription", valid.State) + } + + // Construct a hash set of OVNs as the key + valid.Key = map[scdmodels.OVN]bool{} + if params.Key != nil { + for _, ovn := range *params.Key { + valid.Key[scdmodels.OVN(ovn)] = true + } + } + + return valid, nil +} + +// createAndStoreNewImplicitSubscription will create a brand new implicit subscription based on the provided parameters, +// store it and return it. +func createAndStoreNewImplicitSubscription(ctx context.Context, r repos.Repository, manager dssmodels.Manager, validParams *validOIRParams) (*scdmodels.Subscription, error) { + generator, err := random.Generator(random.MustFromContext(ctx), "implicit-subscription:"+validParams.ID.String()) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to derive implicit subscription ID generator") + } + id, err := scdmodels.NewDeterministicImplicitSubscriptionID(generator) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to create implicit subscription ID") + } + + subToUpsert := scdmodels.Subscription{ + ID: id, + Manager: manager, + StartTime: validParams.UExtent.StartTime, + EndTime: validParams.UExtent.EndTime, + AltitudeLo: validParams.UExtent.SpatialVolume.AltitudeLo, + AltitudeHi: validParams.UExtent.SpatialVolume.AltitudeHi, + Cells: validParams.Cells, + USSBaseURL: validParams.ImplicitSubscription.BaseURL, + NotifyForOperationalIntents: true, + NotifyForConstraints: validParams.ImplicitSubscription.ForConstraints, + ImplicitSubscription: true, + } + + return r.UpsertSubscription(ctx, &subToUpsert) +} + +// validateKeyAndProvideConflictResponse ensures that the provided key contains all the necessary OVNs relevant for the area covered by the OperationalIntent. +// - If all required keys are provided, (nil, nil) will be returned. +// - If keys are missing, the conflict response to be sent back as well as an error with the dsserr.MissingOVNs code will be returned. +// - In case of any other error, (nil, error) will be returned. +func validateKeyAndProvideConflictResponse( + ctx context.Context, + r repos.Repository, + requestingManager dssmodels.Manager, + params *validOIRParams, + attachedSubscription *scdmodels.Subscription, +) (*restapi.AirspaceConflictResponse, error) { + + // Identify OperationalIntents missing from the key + var missingOps []*scdmodels.OperationalIntent + relevantOps, err := r.SearchOperationalIntents(ctx, params.UExtent) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to SearchOperations") + } + for _, relevantOp := range relevantOps { + _, ok := params.Key[relevantOp.OVN] + // Note: The OIR being mutated does not need to be specified in the key: + if !ok && relevantOp.RequiresKey() && relevantOp.ID != params.ID { + missingOps = append(missingOps, relevantOp) + } + } + + // Identify Constraints missing from the key + var missingConstraints []*scdmodels.Constraint + if attachedSubscription != nil && attachedSubscription.NotifyForConstraints { + constraints, err := r.SearchConstraints(ctx, params.UExtent) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to SearchConstraints") + } + for _, relevantConstraint := range constraints { + if _, ok := params.Key[relevantConstraint.OVN]; !ok { + missingConstraints = append(missingConstraints, relevantConstraint) + } + } + } + + // If the client is missing some OVNs, provide the pointers to the + // information they need + if len(missingOps) > 0 || len(missingConstraints) > 0 { + msg := "Current OVNs not provided for one or more OperationalIntents or Constraints" + responseConflict := &restapi.AirspaceConflictResponse{Message: &msg} + + if len(missingOps) > 0 { + responseConflict.MissingOperationalIntents = new([]restapi.OperationalIntentReference) + for _, missingOp := range missingOps { + p := missingOp.ToRest() + // We scrub the OVNs of entities not owned by the requesting manager to make sure + // they have really contacted the managing USS + if missingOp.Manager != requestingManager { + noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) + p.Ovn = &noOvnPhrase + } + *responseConflict.MissingOperationalIntents = append(*responseConflict.MissingOperationalIntents, *p) + } + } + + if len(missingConstraints) > 0 { + responseConflict.MissingConstraints = new([]restapi.ConstraintReference) + for _, missingConstraint := range missingConstraints { + c := missingConstraint.ToRest() + // We scrub the OVNs of entities not owned by the requesting manager to make sure + // they have really contacted the managing USS + if missingConstraint.Manager != requestingManager { + noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) + c.Ovn = &noOvnPhrase + } + *responseConflict.MissingConstraints = append(*responseConflict.MissingConstraints, *c) + } + } + + return responseConflict, stacktrace.NewErrorWithCode(dsserr.MissingOVNs, "Missing OVNs: %v", msg) + } + + return nil, nil +} + +// ensureSubscriptionCoversOIR ensures that the subscription covers the requested geo-temporal extent, extending it if both possible and required, +// or failing otherwise. +// After this method returns successfully, the subscription will cover the requested geo-temporal extent. +func ensureSubscriptionCoversOIR(ctx context.Context, r repos.Repository, sub *scdmodels.Subscription, params *validOIRParams) (*scdmodels.Subscription, error) { + + updateSub := false + if sub.StartTime != nil && sub.StartTime.After(*params.UExtent.StartTime) { + if sub.ImplicitSubscription { + sub.StartTime = params.UExtent.StartTime + updateSub = true + } else { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription does not begin until after the OperationalIntent starts") + } + } + if sub.EndTime != nil && sub.EndTime.Before(*params.UExtent.EndTime) { + if sub.ImplicitSubscription { + sub.EndTime = params.UExtent.EndTime + updateSub = true + } else { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription ends before the OperationalIntent ends") + } + } + if !sub.Cells.Contains(params.Cells) { + if sub.ImplicitSubscription { + sub.Cells = s2.CellUnionFromUnion(sub.Cells, params.Cells) + updateSub = true + } else { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Subscription does not cover entire spatial area of the OperationalIntent") + } + } + if updateSub { + upsertedSub, err := r.UpsertSubscription(ctx, sub) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to update existing Subscription") + } + return upsertedSub, nil + } + + return sub, nil +} + +// PutOperationalIntentReferenceResult is the result of an Operational Intent Reference put operation. +// Exactly one of Response or Conflict is set: Conflict is set when the upsert failed because of missing OVNs (a dsserr.MissingOVNs error is returned alongside it) +// and Response is set on success. +type PutOperationalIntentReferenceResult struct { + Response *restapi.ChangeOperationalIntentReferenceResponse + Conflict *restapi.AirspaceConflictResponse +} + +// executePutOperationalIntentReference inserts or updates an Operational Intent. +// If the ovn argument is empty (""), it will attempt to create a new Operational Intent. +func executePutOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { + var ( + entityid restapi.EntityID + ovn restapi.EntityOVN + params *restapi.PutOperationalIntentReferenceParameters + auth *api.AuthorizationResult + ) + + switch req := request.(type) { + case *restapi.CreateOperationalIntentReferenceRequest: + entityid, params, auth = req.Entityid, req.Body, &req.Auth + case *restapi.UpdateOperationalIntentReferenceRequest: + entityid, ovn, params, auth = req.Entityid, req.Ovn, req.Body, &req.Auth + default: + return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.CreateOperationalIntentReferenceOperationID) + } + + now := timestamp.MustFromContext(ctx) + + // Base URL scheme validation is a pre-flight, request-only check performed by the handler + // before this action is proposed for consensus; skip it here (allowHTTPBaseUrls: true). + validParams, err := ValidateAndReturnOIRUpsertParams(now, entityid, ovn, params, true) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters") + } + manager, err := CheckUpsertPermissionsAndReturnManager(auth, validParams.State) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.PermissionDenied, "Caller is not allowed to upsert with the requested state") + } + + // Get existing OperationalIntent, if any + old, err := repo.GetOperationalIntent(ctx, validParams.ID) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not get OperationalIntent from repo") + } + + // Lock subscriptions based on the cell and subscriptions we're going to use + // to reduce the number of retries under concurrent load. + // See issue #1002 for details. + var subscriptionIds = make([]dssmodels.ID, 0) + + if old != nil && old.SubscriptionID != nil { + subscriptionIds = append(subscriptionIds, *old.SubscriptionID) + } + + if !validParams.SubscriptionID.Empty() { + subscriptionIds = append(subscriptionIds, validParams.SubscriptionID) + } + + err = repo.LockSubscriptionsOnCells(ctx, validParams.Cells, subscriptionIds, validParams.UExtent.StartTime, validParams.UExtent.EndTime) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to acquire lock") + } + + // Validate the request against the previous OIR + if err := validateUpsertRequestAgainstPreviousOIR(manager, validParams.OVN, old); err != nil { + return nil, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "Request validation failed") + } + + var ( + version = scdmodels.VersionNumber(1) + pastOVNs = make([]scdmodels.OVN, 0) + previousSub *scdmodels.Subscription + ) + if old != nil { + version = old.Version + 1 + pastOVNs = append(old.PastOVNs, validParams.OVN) + + // Fetch the previous OIR's subscription if it exists + if old.SubscriptionID != nil { + previousSub, err = repo.GetSubscription(ctx, *old.SubscriptionID) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to get OperationalIntent's Subscription from repo") + } + } + } + + // Determine if the previous subscription is being replaced and if it will need to be cleaned up + previousSubIsBeingReplaced := previousSub != nil && validParams.SubscriptionID != previousSub.ID + removePreviousImplicitSubscription := false + if previousSubIsBeingReplaced { + removePreviousImplicitSubscription, err = SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx, repo, validParams.ID, previousSub) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not determine if previous Subscription can be removed") + } + } + + // attachedSub is the subscription that will end up being attached to the OIR + // it defaults to the previous subscription (which may be nil), and may be updated if required by the parameters + attachedSub := previousSub + if validParams.SubscriptionID.Empty() { + // No subscription ID was provided: + // check if an implicit subscription should be created, otherwise do nothing + if validParams.ImplicitSubscription.Requested { + // Parameters for a new implicit subscription have been passed: we will create + // a new implicit subscription even if another subscription was attached to this OIR before, + // regardless of whether it was an implicit subscription or not. + if attachedSub, err = createAndStoreNewImplicitSubscription(ctx, repo, manager, validParams); err != nil { + return nil, stacktrace.Propagate(err, "Failed to create implicit subscription") + } + } else { + // If no subscription ID is provided and no implicit subscription is requested, + // the OIR should have no attached subscription + attachedSub = nil + } + } else { + // Attempt to rely on the specified subscription + // If it is different from the previous subscription, we need to fetch it from the store + // in order to ensure it correctly covers the OIR. + // We do the check below in order to avoid re-fetching the subscription if it has not changed + if attachedSub == nil || previousSubIsBeingReplaced { + attachedSub, err = repo.GetSubscription(ctx, validParams.SubscriptionID) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to get requested Subscription from store") + } + if attachedSub == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Specified Subscription %s does not exist", validParams.SubscriptionID) + } + } + + // We need to confirm that it is owned by the calling manager + if attachedSub.Manager != manager { + return nil, stacktrace.Propagate( + // We do a bit of wrapping gymnastics because the root error message will be sent in the response, + // and we don't want to include the effective manager in there. + stacktrace.NewErrorWithCode( + dsserr.PermissionDenied, "Specificed Subscription is owned by different client"), + // The propagation message will end in the logs and help with debugging. + "Subscription %s owned by %s, but %s attempted to use it for an OperationalIntent", + validParams.SubscriptionID, + attachedSub.Manager, + manager, + ) + } + + // We need to ensure the subscription covers the OIR's geo-temporal extent + attachedSub, err = ensureSubscriptionCoversOIR(ctx, repo, attachedSub, validParams) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to ensure subscription covers OIR") + } + } + + var responseConflict *restapi.AirspaceConflictResponse + if validParams.State.RequiresKey() { + responseConflict, err = validateKeyAndProvideConflictResponse(ctx, repo, manager, validParams, attachedSub) + if err != nil { + // responseConflict is non-nil here on a dsserr.MissingOVNs error: return it alongside + // the error so the handler can still send it to the client. See the doc comment above. + return &PutOperationalIntentReferenceResult{Conflict: responseConflict}, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "Failed to validate key") + } + } + + // Construct the new OperationalIntent + op := validParams.toOIR(manager, attachedSub, version, pastOVNs) + + // Upsert the OperationalIntent + op, err = repo.UpsertOperationalIntent(ctx, op) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to upsert OperationalIntent in repo") + } + + // Check if the previously attached subscription should be removed + if removePreviousImplicitSubscription { + err = repo.DeleteSubscription(ctx, previousSub.ID) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to delete previous implicit Subscription") + } + } + + notifyVolume, err := computeNotificationVolume(old, validParams.UExtent) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to compute notification volume") + } + + // Notify relevant Subscriptions + subsToNotify, err := repo.IncrementNotificationIndicesForOperationalIntents(ctx, notifyVolume) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to notify relevant Subscriptions") + } + + // Return response to client + return &PutOperationalIntentReferenceResult{ + Response: &restapi.ChangeOperationalIntentReferenceResponse{ + OperationalIntentReference: *op.ToRest(), + Subscribers: makeSubscribersToNotify(subsToNotify), + }, + }, nil +} diff --git a/pkg/scd/actions/registry.go b/pkg/scd/operations/registry.go similarity index 98% rename from pkg/scd/actions/registry.go rename to pkg/scd/operations/registry.go index 60961f40a..c339ea535 100644 --- a/pkg/scd/actions/registry.go +++ b/pkg/scd/operations/registry.go @@ -1,4 +1,4 @@ -package actions +package operations import ( restapi "github.com/interuss/dss/pkg/api/scdv1" diff --git a/pkg/scd/actions/subscription.go b/pkg/scd/operations/subscription.go similarity index 95% rename from pkg/scd/actions/subscription.go rename to pkg/scd/operations/subscription.go index 30106ee29..a4cb4c989 100644 --- a/pkg/scd/actions/subscription.go +++ b/pkg/scd/operations/subscription.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "context" @@ -19,33 +19,33 @@ func init() { Registry[restapi.CreateSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.CreateSubscriptionRequest], - Execute: ExecutePutSubscription, + Execute: executePutSubscription, } Registry[restapi.UpdateSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.UpdateSubscriptionRequest], - Execute: ExecutePutSubscription, + Execute: executePutSubscription, } Registry[restapi.DeleteSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.DeleteSubscriptionRequest], - Execute: ExecuteDeleteSubscription, + Execute: executeDeleteSubscription, } Registry[restapi.GetSubscriptionOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.GetSubscriptionRequest], - Execute: ExecuteGetSubscription, + Execute: executeGetSubscription, IsReadOnly: true, } Registry[restapi.QuerySubscriptionsOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.QuerySubscriptionsRequest], - Execute: ExecuteQuerySubscriptions, + Execute: executeQuerySubscriptions, IsReadOnly: true, } } -func ExecutePutSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executePutSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { var ( manager string subscriptionid restapi.SubscriptionID @@ -119,7 +119,7 @@ func ExecutePutSubscription(ctx context.Context, repo repos.Repository, request } // Validate and perhaps correct StartTime and EndTime. - if err := subreq.AdjustTimeRange(timestamp.MustGetRequestTimestamp(ctx), old); err != nil { + if err := subreq.AdjustTimeRange(timestamp.MustFromContext(ctx), old); err != nil { return nil, stacktrace.Propagate(err, "Error adjusting time range of Subscription") } @@ -253,7 +253,7 @@ func getOperations(ctx context.Context, r repos.Repository, opIDs []dssmodels.ID return res, nil } -func ExecuteDeleteSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeDeleteSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.DeleteSubscriptionRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.DeleteSubscriptionOperationID) @@ -305,7 +305,7 @@ func ExecuteDeleteSubscription(ctx context.Context, repo repos.Repository, reque return &restapi.DeleteSubscriptionResponse{Subscription: *p}, nil } -func ExecuteGetSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeGetSubscription(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.GetSubscriptionRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.GetSubscriptionOperationID) @@ -349,7 +349,7 @@ func ExecuteGetSubscription(ctx context.Context, repo repos.Repository, request return &restapi.GetSubscriptionResponse{Subscription: *p}, nil } -func ExecuteQuerySubscriptions(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +func executeQuerySubscriptions(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.QuerySubscriptionsRequest) if !ok { return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.QuerySubscriptionsOperationID) @@ -373,7 +373,7 @@ func ExecuteQuerySubscriptions(ctx context.Context, repo repos.Repository, reque return nil, stacktrace.Propagate(err, "Error searching Subscriptions in repo") } - nowMarker := timestamp.MustGetRequestTimestamp(ctx) + nowMarker := timestamp.MustFromContext(ctx) // Return response to client response := &restapi.QuerySubscriptionsResponse{ diff --git a/pkg/scd/server.go b/pkg/scd/server.go index a8eab0aaa..9dea0d488 100644 --- a/pkg/scd/server.go +++ b/pkg/scd/server.go @@ -1,32 +1,9 @@ package scd import ( - restapi "github.com/interuss/dss/pkg/api/scdv1" - scdmodels "github.com/interuss/dss/pkg/scd/models" scdstore "github.com/interuss/dss/pkg/scd/store" ) -func makeSubscribersToNotify(subscriptions []*scdmodels.Subscription) []restapi.SubscriberToNotify { - result := []restapi.SubscriberToNotify{} - - subscriptionsByURL := map[string][]restapi.SubscriptionState{} - for _, sub := range subscriptions { - subState := restapi.SubscriptionState{ - SubscriptionId: restapi.SubscriptionID(sub.ID.String()), - NotificationIndex: restapi.SubscriptionNotificationIndex(sub.NotificationIndex), - } - subscriptionsByURL[sub.USSBaseURL] = append(subscriptionsByURL[sub.USSBaseURL], subState) - } - for url, states := range subscriptionsByURL { - result = append(result, restapi.SubscriberToNotify{ - UssBaseUrl: restapi.SubscriptionUssBaseURL(url), - Subscriptions: states, - }) - } - - return result -} - // Server implements scdv1.Implementation. type Server struct { Store scdstore.Store diff --git a/pkg/scd/store/memstore/availability.go b/pkg/scd/store/memstore/availability.go index bb750996a..8fe0dc839 100644 --- a/pkg/scd/store/memstore/availability.go +++ b/pkg/scd/store/memstore/availability.go @@ -26,7 +26,7 @@ func (r *repo) GetUssAvailability(_ context.Context, id dssmodels.Manager) (*scd } func (r *repo) UpsertUssAvailability(ctx context.Context, s *scdmodels.UssAvailabilityStatus) (*scdmodels.UssAvailabilityStatus, error) { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := &availabilityRecord{ Uss: s.Uss, diff --git a/pkg/scd/store/memstore/constraints.go b/pkg/scd/store/memstore/constraints.go index 6fb54f143..33c9a0b6a 100644 --- a/pkg/scd/store/memstore/constraints.go +++ b/pkg/scd/store/memstore/constraints.go @@ -67,7 +67,7 @@ func (r *repo) UpsertConstraint(ctx context.Context, s *scdmodels.Constraint) (* return nil, stacktrace.Propagate(err, "Failed to convert array to jackc/pgtype") } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := &constraintRecord{ ID: s.ID, diff --git a/pkg/scd/store/memstore/operational_intents.go b/pkg/scd/store/memstore/operational_intents.go index fad0deec2..0abbab93f 100644 --- a/pkg/scd/store/memstore/operational_intents.go +++ b/pkg/scd/store/memstore/operational_intents.go @@ -98,7 +98,7 @@ func (r *repo) UpsertOperationalIntent(ctx context.Context, operation *scdmodels ussRequestedOVN = operation.OVN.String() } - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := &operationalIntentRecord{ ID: operation.ID, diff --git a/pkg/scd/store/memstore/store_test.go b/pkg/scd/store/memstore/store_test.go index 7f43bdf8c..caecd5bc6 100644 --- a/pkg/scd/store/memstore/store_test.go +++ b/pkg/scd/store/memstore/store_test.go @@ -40,7 +40,7 @@ func setUpStore(t *testing.T) *repo { // writeCtx returns a context carrying a deterministic write timestamp so that // updated_at is controlled in tests. func writeCtx() context.Context { - return timestamp.WithRequestTimestamp(context.Background(), writeTime) + return timestamp.NewContext(context.Background(), writeTime) } func sampleConstraint() *scdmodels.Constraint { diff --git a/pkg/scd/store/memstore/subscriptions.go b/pkg/scd/store/memstore/subscriptions.go index 02f233698..850d06125 100644 --- a/pkg/scd/store/memstore/subscriptions.go +++ b/pkg/scd/store/memstore/subscriptions.go @@ -78,7 +78,7 @@ func (r *repo) GetSubscription(_ context.Context, id dssmodels.ID) (*scdmodels.S } func (r *repo) UpsertSubscription(ctx context.Context, s *scdmodels.Subscription) (*scdmodels.Subscription, error) { - now := timestamp.MustGetRequestTimestamp(ctx) + now := timestamp.MustFromContext(ctx) rec := &subscriptionRecord{ ID: s.ID, diff --git a/pkg/scd/store/raftstore/availability.go b/pkg/scd/store/raftstore/availability.go index 7e45f9c4b..7a6092ed9 100644 --- a/pkg/scd/store/raftstore/availability.go +++ b/pkg/scd/store/raftstore/availability.go @@ -2,17 +2,68 @@ package raftstore import ( "context" + "encoding/json" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/raftstore/consensus" scdmodels "github.com/interuss/dss/pkg/scd/models" "github.com/interuss/stacktrace" ) -func (r *repo) GetUssAvailability(_ context.Context, id dssmodels.Manager) (*scdmodels.UssAvailabilityStatus, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetUssAvailability not implemented for raftstore") +const ( + getUssAvailability consensus.RequestType = "getUssAvailability" + upsertUssAvailability consensus.RequestType = "upsertUssAvailability" +) + +func (r *repo) GetUssAvailability(ctx context.Context, id dssmodels.Manager) (*scdmodels.UssAvailabilityStatus, error) { + buf, err := json.Marshal(id) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getUssAvailability, buf, true) + if err != nil { + return nil, err + } + if ussa, ok := result.(*scdmodels.UssAvailabilityStatus); ok { + return ussa, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) UpsertUssAvailability(ctx context.Context, ussa *scdmodels.UssAvailabilityStatus) (*scdmodels.UssAvailabilityStatus, error) { + buf, err := json.Marshal(ussa) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, upsertUssAvailability, buf, false) + if err != nil { + return nil, err + } + if upserted, ok := result.(*scdmodels.UssAvailabilityStatus); ok { + return upserted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) UpsertUssAvailability(_ context.Context, ussa *scdmodels.UssAvailabilityStatus) (*scdmodels.UssAvailabilityStatus, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertUssAvailability not implemented for raftstore") +func (r *repo) applyAvailability(ctx context.Context, proposal consensus.Proposal) (any, error) { + switch proposal.RequestType { + case getUssAvailability: + var manager dssmodels.Manager + if err := json.Unmarshal(proposal.Value, &manager); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getUssAvailability) + } + return r.Store.GetRepo().GetUssAvailability(ctx, manager) + + case upsertUssAvailability: + var ussa scdmodels.UssAvailabilityStatus + if err := json.Unmarshal(proposal.Value, &ussa); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", upsertUssAvailability) + } + return r.Store.GetRepo().UpsertUssAvailability(ctx, &ussa) + + default: + return nil, stacktrace.NewError("unrecognized availability request type: %s", proposal.RequestType) + } } diff --git a/pkg/scd/store/raftstore/constraints.go b/pkg/scd/store/raftstore/constraints.go index 0983add14..c2bc08626 100644 --- a/pkg/scd/store/raftstore/constraints.go +++ b/pkg/scd/store/raftstore/constraints.go @@ -2,29 +2,125 @@ package raftstore import ( "context" + "encoding/json" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/raftstore/consensus" scdmodels "github.com/interuss/dss/pkg/scd/models" "github.com/interuss/stacktrace" ) -func (r *repo) SearchConstraints(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchConstraints not implemented for raftstore") +const ( + searchConstraints consensus.RequestType = "searchConstraints" + getConstraint consensus.RequestType = "getConstraint" + upsertConstraint consensus.RequestType = "upsertConstraint" + deleteConstraint consensus.RequestType = "deleteConstraint" + countConstraints consensus.RequestType = "countConstraints" +) + +func (r *repo) SearchConstraints(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Constraint, error) { + buf, err := json.Marshal(v4d) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, searchConstraints, buf, true) + if err != nil { + return nil, err + } + if constraints, ok := result.([]*scdmodels.Constraint); ok { + return constraints, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) GetConstraint(_ context.Context, id dssmodels.ID) (*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetConstraint not implemented for raftstore") +func (r *repo) GetConstraint(ctx context.Context, id dssmodels.ID) (*scdmodels.Constraint, error) { + buf, err := json.Marshal(id) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getConstraint, buf, true) + if err != nil { + return nil, err + } + if constraint, ok := result.(*scdmodels.Constraint); ok { + return constraint, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) UpsertConstraint(_ context.Context, constraint *scdmodels.Constraint) (*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertConstraint not implemented for raftstore") +func (r *repo) UpsertConstraint(ctx context.Context, constraint *scdmodels.Constraint) (*scdmodels.Constraint, error) { + buf, err := json.Marshal(constraint) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, upsertConstraint, buf, false) + if err != nil { + return nil, err + } + if upserted, ok := result.(*scdmodels.Constraint); ok { + return upserted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) DeleteConstraint(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteConstraint not implemented for raftstore") +func (r *repo) DeleteConstraint(ctx context.Context, id dssmodels.ID) error { + buf, err := json.Marshal(id) + if err != nil { + return stacktrace.Propagate(err, "failed to marshal payload") + } + + _, err = r.consensus.HandleClientRequest(ctx, deleteConstraint, buf, false) + return err } -func (r *repo) CountConstraints(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountConstraint not implemented for raftstore") +func (r *repo) CountConstraints(ctx context.Context) (int64, error) { + result, err := r.consensus.HandleClientRequest(ctx, countConstraints, nil, true) + if err != nil { + return 0, err + } + if count, ok := result.(int64); ok { + return count, nil + } + return 0, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) applyConstraint(ctx context.Context, proposal consensus.Proposal) (any, error) { + switch proposal.RequestType { + case searchConstraints: + var v4d dssmodels.Volume4D + if err := json.Unmarshal(proposal.Value, &v4d); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", searchConstraints) + } + return r.Store.GetRepo().SearchConstraints(ctx, &v4d) + + case getConstraint: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getConstraint) + } + return r.Store.GetRepo().GetConstraint(ctx, id) + + case upsertConstraint: + var constraint scdmodels.Constraint + if err := json.Unmarshal(proposal.Value, &constraint); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", upsertConstraint) + } + return r.Store.GetRepo().UpsertConstraint(ctx, &constraint) + + case deleteConstraint: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", deleteConstraint) + } + return nil, r.Store.GetRepo().DeleteConstraint(ctx, id) + + case countConstraints: + return r.Store.GetRepo().CountConstraints(ctx) + + default: + return nil, stacktrace.NewError("unrecognized constraint request type: %s", proposal.RequestType) + } } diff --git a/pkg/scd/store/raftstore/operational_intents.go b/pkg/scd/store/raftstore/operational_intents.go index 3a6af6acd..674890513 100644 --- a/pkg/scd/store/raftstore/operational_intents.go +++ b/pkg/scd/store/raftstore/operational_intents.go @@ -2,38 +2,174 @@ package raftstore import ( "context" + "encoding/json" "time" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/raftstore/consensus" scdmodels "github.com/interuss/dss/pkg/scd/models" "github.com/interuss/stacktrace" ) -func (r *repo) GetOperationalIntent(_ context.Context, id dssmodels.ID) (*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetOperationalIntent not implemented for raftstore") +const ( + getOperationalIntent consensus.RequestType = "getOperationalIntent" + deleteOperationalIntent consensus.RequestType = "deleteOperationalIntent" + upsertOperationalIntent consensus.RequestType = "upsertOperationalIntent" + searchOperationalIntents consensus.RequestType = "searchOperationalIntents" + getDependentOperationalIntents consensus.RequestType = "getDependentOperationalIntents" + listExpiredOperationalIntents consensus.RequestType = "listExpiredOperationalIntents" + countOperationalIntents consensus.RequestType = "countOperationalIntents" +) + +func (r *repo) GetOperationalIntent(ctx context.Context, id dssmodels.ID) (*scdmodels.OperationalIntent, error) { + buf, err := json.Marshal(id) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getOperationalIntent, buf, true) + if err != nil { + return nil, err + } + if operation, ok := result.(*scdmodels.OperationalIntent); ok { + return operation, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) DeleteOperationalIntent(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteOperationalIntent not implemented for raftstore") +func (r *repo) DeleteOperationalIntent(ctx context.Context, id dssmodels.ID) error { + buf, err := json.Marshal(id) + if err != nil { + return stacktrace.Propagate(err, "failed to marshal payload") + } + + _, err = r.consensus.HandleClientRequest(ctx, deleteOperationalIntent, buf, false) + return err } -func (r *repo) UpsertOperationalIntent(_ context.Context, operation *scdmodels.OperationalIntent) (*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertOperationalIntent not implemented for raftstore") +func (r *repo) UpsertOperationalIntent(ctx context.Context, operation *scdmodels.OperationalIntent) (*scdmodels.OperationalIntent, error) { + buf, err := json.Marshal(operation) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, upsertOperationalIntent, buf, false) + if err != nil { + return nil, err + } + if upserted, ok := result.(*scdmodels.OperationalIntent); ok { + return upserted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) SearchOperationalIntents(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchOperationalIntents not implemented for raftstore") +func (r *repo) SearchOperationalIntents(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.OperationalIntent, error) { + buf, err := json.Marshal(v4d) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, searchOperationalIntents, buf, true) + if err != nil { + return nil, err + } + if operations, ok := result.([]*scdmodels.OperationalIntent); ok { + return operations, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) GetDependentOperationalIntents(_ context.Context, subscriptionID dssmodels.ID) ([]dssmodels.ID, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetDependentOperationalIntents not implemented for raftstore") +func (r *repo) GetDependentOperationalIntents(ctx context.Context, subscriptionID dssmodels.ID) ([]dssmodels.ID, error) { + buf, err := json.Marshal(subscriptionID) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getDependentOperationalIntents, buf, true) + if err != nil { + return nil, err + } + if ids, ok := result.([]dssmodels.ID); ok { + return ids, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) ListExpiredOperationalIntents(_ context.Context, threshold time.Time) ([]*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "ListExpiredOperationalIntents not implemented for raftstore") +func (r *repo) ListExpiredOperationalIntents(ctx context.Context, threshold time.Time) ([]*scdmodels.OperationalIntent, error) { + buf, err := json.Marshal(threshold) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, listExpiredOperationalIntents, buf, true) + if err != nil { + return nil, err + } + if operations, ok := result.([]*scdmodels.OperationalIntent); ok { + return operations, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) CountOperationalIntents(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountOperationalIntents not implemented for raftstore") +func (r *repo) CountOperationalIntents(ctx context.Context) (int64, error) { + result, err := r.consensus.HandleClientRequest(ctx, countOperationalIntents, nil, true) + if err != nil { + return 0, err + } + if count, ok := result.(int64); ok { + return count, nil + } + return 0, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) applyOperationalIntent(ctx context.Context, proposal consensus.Proposal) (any, error) { + switch proposal.RequestType { + case getOperationalIntent: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getOperationalIntent) + } + return r.Store.GetRepo().GetOperationalIntent(ctx, id) + + case deleteOperationalIntent: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", deleteOperationalIntent) + } + return nil, r.Store.GetRepo().DeleteOperationalIntent(ctx, id) + + case upsertOperationalIntent: + var operation scdmodels.OperationalIntent + if err := json.Unmarshal(proposal.Value, &operation); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", upsertOperationalIntent) + } + return r.Store.GetRepo().UpsertOperationalIntent(ctx, &operation) + + case searchOperationalIntents: + var v4d dssmodels.Volume4D + if err := json.Unmarshal(proposal.Value, &v4d); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", searchOperationalIntents) + } + return r.Store.GetRepo().SearchOperationalIntents(ctx, &v4d) + + case getDependentOperationalIntents: + var subscriptionID dssmodels.ID + if err := json.Unmarshal(proposal.Value, &subscriptionID); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getDependentOperationalIntents) + } + return r.Store.GetRepo().GetDependentOperationalIntents(ctx, subscriptionID) + + case listExpiredOperationalIntents: + var threshold time.Time + if err := json.Unmarshal(proposal.Value, &threshold); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", listExpiredOperationalIntents) + } + return r.Store.GetRepo().ListExpiredOperationalIntents(ctx, threshold) + + case countOperationalIntents: + return r.Store.GetRepo().CountOperationalIntents(ctx) + + default: + return nil, stacktrace.NewError("unrecognized operational intent request type: %s", proposal.RequestType) + } } diff --git a/pkg/scd/store/raftstore/store.go b/pkg/scd/store/raftstore/store.go index 918499c25..4f58487e8 100644 --- a/pkg/scd/store/raftstore/store.go +++ b/pkg/scd/store/raftstore/store.go @@ -6,7 +6,7 @@ import ( "github.com/interuss/dss/pkg/memstore" "github.com/interuss/dss/pkg/raftstore" "github.com/interuss/dss/pkg/raftstore/consensus" - "github.com/interuss/dss/pkg/scd/actions" + "github.com/interuss/dss/pkg/scd/operations" "github.com/interuss/dss/pkg/scd/repos" scdmemstore "github.com/interuss/dss/pkg/scd/store/memstore" scdraftparams "github.com/interuss/dss/pkg/scd/store/raftstore/params" @@ -17,8 +17,7 @@ import ( // repo is a full implementation of scd.repos.Repository for Raft-based storage. type repo struct { consensus *consensus.Consensus - memStore *memstore.Store[repos.Repository] - memRepo repos.Repository + *memstore.Store[repos.Repository] } func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore.Store[repos.Repository], error) { @@ -32,8 +31,8 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. return nil, stacktrace.Propagate(err, "failed to initialize scd memstore") } - r := &repo{memStore: memStore, memRepo: memStore.GetRepo()} - store, err := raftstore.Init(ctx, logger.With(zap.String("service", "scd")), locality, params, r, actions.Registry) + r := &repo{Store: memStore} + store, err := raftstore.Init(ctx, logger.With(zap.String("service", "scd")), locality, params, r, operations.Registry) if err != nil { return nil, stacktrace.Propagate(err, "failed to initialize scd raftstore") } @@ -45,19 +44,25 @@ func Init(ctx context.Context, logger *zap.Logger, locality string) (*raftstore. func (r *repo) GetRepo() repos.Repository { return r } -func (r *repo) GetSnapshot() ([]byte, error) { - return r.memStore.GetSnapshot() -} - -func (r *repo) RestoreFromSnapshot(data []byte) error { - return r.memStore.RestoreFromSnapshot(data) -} - func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, error) { switch proposal.RequestType { + case searchConstraints, getConstraint, upsertConstraint, deleteConstraint, countConstraints: + return r.applyConstraint(ctx, proposal) + + case searchSubscriptions, getSubscription, upsertSubscription, deleteSubscription, + incrementNotificationIndicesForOperationalIntents, incrementNotificationIndicesForConstraints, + listExpiredSubscriptions, countSubscriptions: + return r.applySubscription(ctx, proposal) + + case getOperationalIntent, deleteOperationalIntent, upsertOperationalIntent, searchOperationalIntents, + getDependentOperationalIntents, listExpiredOperationalIntents, countOperationalIntents: + return r.applyOperationalIntent(ctx, proposal) + + case getUssAvailability, upsertUssAvailability: + return r.applyAvailability(ctx, proposal) default: - handler, ok := actions.Registry[string(proposal.RequestType)] + handler, ok := operations.Registry[string(proposal.RequestType)] if !ok { return nil, stacktrace.NewError("unrecognized request type: %s", proposal.RequestType) } @@ -67,6 +72,6 @@ func (r *repo) Apply(ctx context.Context, proposal consensus.Proposal) (any, err return nil, stacktrace.Propagate(err, "failed to decode %s payload", proposal.RequestType) } - return handler.Execute(ctx, r.memRepo, request) + return handler.Execute(ctx, r.Store.GetRepo(), request) } } diff --git a/pkg/scd/store/raftstore/subscriptions.go b/pkg/scd/store/raftstore/subscriptions.go index 2e5609335..47a5806f5 100644 --- a/pkg/scd/store/raftstore/subscriptions.go +++ b/pkg/scd/store/raftstore/subscriptions.go @@ -2,47 +2,196 @@ package raftstore import ( "context" + "encoding/json" "time" "github.com/golang/geo/s2" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" + "github.com/interuss/dss/pkg/raftstore/consensus" scdmodels "github.com/interuss/dss/pkg/scd/models" "github.com/interuss/stacktrace" ) -func (r *repo) SearchSubscriptions(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchSubscriptions not implemented for raftstore") +const ( + searchSubscriptions consensus.RequestType = "searchSubscriptions" + getSubscription consensus.RequestType = "getSubscription" + upsertSubscription consensus.RequestType = "upsertSubscription" + deleteSubscription consensus.RequestType = "deleteSubscription" + incrementNotificationIndicesForOperationalIntents consensus.RequestType = "incrementNotificationIndicesForOperationalIntents" + incrementNotificationIndicesForConstraints consensus.RequestType = "incrementNotificationIndicesForConstraints" + listExpiredSubscriptions consensus.RequestType = "listExpiredSubscriptions" + countSubscriptions consensus.RequestType = "countSubscriptions" +) + +func (r *repo) SearchSubscriptions(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { + buf, err := json.Marshal(v4d) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, searchSubscriptions, buf, true) + if err != nil { + return nil, err + } + if subscriptions, ok := result.([]*scdmodels.Subscription); ok { + return subscriptions, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) GetSubscription(ctx context.Context, id dssmodels.ID) (*scdmodels.Subscription, error) { + buf, err := json.Marshal(id) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, getSubscription, buf, true) + if err != nil { + return nil, err + } + if sub, ok := result.(*scdmodels.Subscription); ok { + return sub, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) GetSubscription(_ context.Context, id dssmodels.ID) (*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetSubscription not implemented for raftstore") +func (r *repo) UpsertSubscription(ctx context.Context, sub *scdmodels.Subscription) (*scdmodels.Subscription, error) { + buf, err := json.Marshal(sub) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, upsertSubscription, buf, false) + if err != nil { + return nil, err + } + if upserted, ok := result.(*scdmodels.Subscription); ok { + return upserted, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) +} + +func (r *repo) DeleteSubscription(ctx context.Context, id dssmodels.ID) error { + buf, err := json.Marshal(id) + if err != nil { + return stacktrace.Propagate(err, "failed to marshal payload") + } + + _, err = r.consensus.HandleClientRequest(ctx, deleteSubscription, buf, false) + return err } -func (r *repo) UpsertSubscription(_ context.Context, sub *scdmodels.Subscription) (*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertSubscription not implemented for raftstore") +func (r *repo) IncrementNotificationIndicesForOperationalIntents(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { + return r.incrementNotificationIndices(ctx, incrementNotificationIndicesForOperationalIntents, v4d) } -func (r *repo) DeleteSubscription(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteSubscription not implemented for raftstore") +func (r *repo) IncrementNotificationIndicesForConstraints(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { + return r.incrementNotificationIndices(ctx, incrementNotificationIndicesForConstraints, v4d) } -func (r *repo) IncrementNotificationIndicesForOperationalIntents(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "IncrementNotificationIndicesForOperationalIntents not implemented for raftstore") +func (r *repo) incrementNotificationIndices(ctx context.Context, requestType consensus.RequestType, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { + buf, err := json.Marshal(v4d) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, requestType, buf, false) + if err != nil { + return nil, err + } + if subscriptions, ok := result.([]*scdmodels.Subscription); ok { + return subscriptions, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) IncrementNotificationIndicesForConstraints(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "IncrementNotificationIndicesForConstraints not implemented for raftstore") +// LockSubscriptionsOnCells is a no-op in the raftstore implementation +func (r *repo) LockSubscriptionsOnCells(_ context.Context, _ s2.CellUnion, _ []dssmodels.ID, _ *time.Time, _ *time.Time) error { + return nil } -func (r *repo) LockSubscriptionsOnCells(_ context.Context, cells s2.CellUnion, subscriptionIds []dssmodels.ID, startTime *time.Time, endTime *time.Time) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "LockSubscriptionsOnCells not implemented for raftstore") +func (r *repo) ListExpiredSubscriptions(ctx context.Context, threshold time.Time) ([]*scdmodels.Subscription, error) { + buf, err := json.Marshal(threshold) + if err != nil { + return nil, stacktrace.Propagate(err, "failed to marshal payload") + } + + result, err := r.consensus.HandleClientRequest(ctx, listExpiredSubscriptions, buf, true) + if err != nil { + return nil, err + } + if subscriptions, ok := result.([]*scdmodels.Subscription); ok { + return subscriptions, nil + } + return nil, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) ListExpiredSubscriptions(_ context.Context, threshold time.Time) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "ListExpiredSubscriptions not implemented for raftstore") +func (r *repo) CountSubscriptions(ctx context.Context) (int64, error) { + result, err := r.consensus.HandleClientRequest(ctx, countSubscriptions, nil, true) + if err != nil { + return 0, err + } + if count, ok := result.(int64); ok { + return count, nil + } + return 0, stacktrace.NewError("unexpected result type: %T", result) } -func (r *repo) CountSubscriptions(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountSubscriptions not implemented for raftstore") +func (r *repo) applySubscription(ctx context.Context, proposal consensus.Proposal) (any, error) { + switch proposal.RequestType { + case searchSubscriptions: + var v4d dssmodels.Volume4D + if err := json.Unmarshal(proposal.Value, &v4d); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", searchSubscriptions) + } + return r.Store.GetRepo().SearchSubscriptions(ctx, &v4d) + + case getSubscription: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", getSubscription) + } + return r.Store.GetRepo().GetSubscription(ctx, id) + + case upsertSubscription: + var sub scdmodels.Subscription + if err := json.Unmarshal(proposal.Value, &sub); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", upsertSubscription) + } + return r.Store.GetRepo().UpsertSubscription(ctx, &sub) + + case deleteSubscription: + var id dssmodels.ID + if err := json.Unmarshal(proposal.Value, &id); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", deleteSubscription) + } + return nil, r.Store.GetRepo().DeleteSubscription(ctx, id) + + case incrementNotificationIndicesForOperationalIntents: + var v4d dssmodels.Volume4D + if err := json.Unmarshal(proposal.Value, &v4d); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", incrementNotificationIndicesForOperationalIntents) + } + return r.Store.GetRepo().IncrementNotificationIndicesForOperationalIntents(ctx, &v4d) + + case incrementNotificationIndicesForConstraints: + var v4d dssmodels.Volume4D + if err := json.Unmarshal(proposal.Value, &v4d); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", incrementNotificationIndicesForConstraints) + } + return r.Store.GetRepo().IncrementNotificationIndicesForConstraints(ctx, &v4d) + + case listExpiredSubscriptions: + var threshold time.Time + if err := json.Unmarshal(proposal.Value, &threshold); err != nil { + return nil, stacktrace.Propagate(err, "failed to unmarshal %s payload", listExpiredSubscriptions) + } + return r.Store.GetRepo().ListExpiredSubscriptions(ctx, threshold) + + case countSubscriptions: + return r.Store.GetRepo().CountSubscriptions(ctx) + + default: + return nil, stacktrace.NewError("unrecognized subscription request type: %s", proposal.RequestType) + } } diff --git a/pkg/scd/store/sqlstore/store.go b/pkg/scd/store/sqlstore/store.go index 2dd58d4c3..d4a2b510d 100644 --- a/pkg/scd/store/sqlstore/store.go +++ b/pkg/scd/store/sqlstore/store.go @@ -6,7 +6,7 @@ import ( dssql "github.com/interuss/dss/pkg/sql" "github.com/interuss/dss/pkg/logging" - "github.com/interuss/dss/pkg/scd/actions" + "github.com/interuss/dss/pkg/scd/operations" "github.com/interuss/dss/pkg/scd/repos" "github.com/interuss/dss/pkg/sqlstore" "github.com/interuss/dss/pkg/store/params" @@ -51,6 +51,6 @@ func Init(ctx context.Context, logger *zap.Logger, withCheckCron bool) (*sqlstor version: version, } }, - Registry: actions.Registry, + Registry: operations.Registry, }, withCheckCron) } diff --git a/pkg/store/store.go b/pkg/store/store.go index 4b87b0a67..7102c7e95 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -54,17 +54,18 @@ func DecodeJSON[T OperationRequest](buf []byte) (OperationRequest, error) { } // TransactWithResult wraps Store.Transact and casts the result to ResultType, avoiding a cast at every call site. +// The cast is attempted even when Transact returns an error, since some operations intentionally return +// a partial result alongside an error (e.g. a conflict response). func TransactWithResult[R any, ResultType any](ctx context.Context, store Store[R], request OperationRequest) (ResultType, error) { var empty ResultType transactionResult, err := store.Transact(ctx, request) + if resultType, ok := transactionResult.(ResultType); ok { + return resultType, err + } if err != nil { return empty, err } - resultType, ok := transactionResult.(ResultType) - if !ok { - return empty, stacktrace.NewError("unexpected result type %T, want %T", transactionResult, empty) - } - return resultType, nil + return empty, stacktrace.NewError("unexpected result type %T, want %T", transactionResult, empty) } // FuncOperation wraps a closure as an OperationRequest for gradual migration. diff --git a/pkg/timestamp/timestamp.go b/pkg/timestamp/timestamp.go index 28dbe9451..fe1329d46 100644 --- a/pkg/timestamp/timestamp.go +++ b/pkg/timestamp/timestamp.go @@ -8,13 +8,13 @@ import ( "github.com/interuss/stacktrace" ) -type timestampKey struct{} +type key struct{} -// requestTimestampFromContext returns the request timestamp from the context, or an error if the value is not present or if it is zero. +// fromContext returns the request timestamp from the context, or an error if the value is not present or if it is zero. // The timestamp is set by the Middleware when a query is received then (on the receiver side) by the Raftstore when the query is applied. // It is then used for deterministic execution of time-dependent queries. -func requestTimestampFromContext(ctx context.Context) (time.Time, error) { - timestamp, ok := ctx.Value(timestampKey{}).(time.Time) +func fromContext(ctx context.Context) (time.Time, error) { + timestamp, ok := ctx.Value(key{}).(time.Time) if !ok { return time.Time{}, stacktrace.NewError("timestamp not found in context") } @@ -26,10 +26,10 @@ func requestTimestampFromContext(ctx context.Context) (time.Time, error) { return timestamp, nil } -// MustGetRequestTimestamp returns the request timestamp from the context and panics if it is not +// MustFromContext returns the request timestamp from the context and panics if it is not // present or invalid, which is a programming error. -func MustGetRequestTimestamp(ctx context.Context) time.Time { - timestamp, err := requestTimestampFromContext(ctx) +func MustFromContext(ctx context.Context) time.Time { + timestamp, err := fromContext(ctx) if err != nil { panic(err) } @@ -37,18 +37,18 @@ func MustGetRequestTimestamp(ctx context.Context) time.Time { return timestamp } -// WithRequestTimestamp returns a new context with the given timestamp. -func WithRequestTimestamp(ctx context.Context, timestamp time.Time) context.Context { - return context.WithValue(ctx, timestampKey{}, timestamp) +// NewContext returns a new context with the given timestamp. +func NewContext(ctx context.Context, timestamp time.Time) context.Context { + return context.WithValue(ctx, key{}, timestamp) } -// RequestTimestampMiddleware is an HTTP middleware that stamps each incoming +// Middleware is an HTTP middleware that stamps each incoming // request with its received time. This timestamp is later used as the // timestamp of the Raft proposal, so that time-dependent queries // execute deterministically across nodes and contexts (catchup / restart etc.). -func RequestTimestampMiddleware(next http.Handler) http.Handler { +func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := WithRequestTimestamp(r.Context(), time.Now()) + ctx := NewContext(r.Context(), time.Now()) next.ServeHTTP(w, r.WithContext(ctx)) }) }