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/errors/errors.go b/pkg/errors/errors.go index f525e3b90..ccf10b2aa 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -16,10 +16,6 @@ const ( // larger than the max area allowed. See geo/s2.go. AreaTooLarge = stacktrace.ErrorCode(iota) - // MissingOVNs is the error to signal that an AirspaceConflictResponse should - // be returned rather than the standard error response. - MissingOVNs - // AlreadyExists is used when attempting to create a resource that already // exists. AlreadyExists 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/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/store.go b/pkg/rid/store/raftstore/store.go index 6dabdecf7..cfbf6dd51 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,11 @@ 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 { 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 +58,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/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 3e8cce828..8e00f28f9 100644 --- a/pkg/scd/operational_intents_handler.go +++ b/pkg/scd/operational_intents_handler.go @@ -2,16 +2,16 @@ package scd import ( "context" - "time" "github.com/interuss/dss/pkg/api" 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" "github.com/interuss/stacktrace" ) @@ -138,8 +138,18 @@ 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"))}} } + validParams, err := operations.ValidateAndReturnOIRUpsertParams(timestamp.MustFromContext(ctx), req.Entityid, "", req.Body, a.AllowHTTPBaseUrls) + if 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"))}} + } + _, err = operations.CheckUpsertPermissionsAndReturnManager(&req.Auth, validParams.State) + if err != nil { + return restapi.CreateOperationalIntentReferenceResponseSet{Response403: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.PermissionDenied, "Caller is not allowed to upsert with the requested state"))}} + } - 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)} @@ -151,15 +161,17 @@ func (a *Server) CreateOperationalIntentReference(ctx context.Context, req *rest case dsserr.VersionMismatch: return restapi.CreateOperationalIntentReferenceResponseSet{Response409: &restapi.AirspaceConflictResponse{ Message: dsserr.Handle(ctx, err)}} - case dsserr.MissingOVNs: - return restapi.CreateOperationalIntentReferenceResponseSet{Response409: respConflict} default: return restapi.CreateOperationalIntentReferenceResponseSet{Response500: &api.InternalServerErrorBody{ ErrorMessage: *dsserr.Handle(ctx, stacktrace.Propagate(err, "Got an unexpected error"))}} } } - return restapi.CreateOperationalIntentReferenceResponseSet{Response201: respOK} + if result.Conflict != nil { + return restapi.CreateOperationalIntentReferenceResponseSet{Response409: result.Conflict} + } + + return restapi.CreateOperationalIntentReferenceResponseSet{Response201: result.Response} } func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *restapi.UpdateOperationalIntentReferenceRequest, @@ -169,10 +181,20 @@ 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"))}} } + validParams, err := operations.ValidateAndReturnOIRUpsertParams(timestamp.MustFromContext(ctx), req.Entityid, req.Ovn, req.Body, a.AllowHTTPBaseUrls) + if 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"))}} + } + _, err = operations.CheckUpsertPermissionsAndReturnManager(&req.Auth, validParams.State) + if err != nil { + return restapi.UpdateOperationalIntentReferenceResponseSet{Response403: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.PermissionDenied, "Caller is not allowed to upsert with the requested state"))}} + } - 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: @@ -182,196 +204,15 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest case dsserr.VersionMismatch: return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: &restapi.AirspaceConflictResponse{ Message: dsserr.Handle(ctx, err)}} - case dsserr.MissingOVNs: - return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: respConflict} default: return restapi.UpdateOperationalIntentReferenceResponseSet{Response500: &api.InternalServerErrorBody{ ErrorMessage: *dsserr.Handle(ctx, stacktrace.Propagate(err, "Got an unexpected error"))}} } } - return restapi.UpdateOperationalIntentReferenceResponseSet{Response200: respOK} -} - -// 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 := actions.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 := actions.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 := actions.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 = actions.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 = actions.EnsureSubscriptionCoversOIR(ctx, r, attachedSub, validParams) - if err != nil { - return stacktrace.Propagate(err, "Failed to ensure subscription covers OIR") - } - } - - if validParams.State.RequiresKey() { - responseConflict, err = actions.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 := actions.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 + if result.Conflict != nil { + return restapi.UpdateOperationalIntentReferenceResponseSet{Response409: result.Conflict} } - 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/actions/operational_intents.go b/pkg/scd/operations/operational_intents.go similarity index 69% rename from pkg/scd/actions/operational_intents.go rename to pkg/scd/operations/operational_intents.go index 51f2162ed..76216c814 100644 --- a/pkg/scd/actions/operational_intents.go +++ b/pkg/scd/operations/operational_intents.go @@ -1,4 +1,4 @@ -package actions +package operations import ( "context" @@ -14,6 +14,7 @@ import ( 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" ) @@ -21,19 +22,29 @@ func init() { Registry[restapi.GetOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.GetOperationalIntentReferenceRequest], - Execute: ExecuteGetOperationalIntentReference, + Execute: executeGetOperationalIntentReference, IsReadOnly: true, } Registry[restapi.QueryOperationalIntentReferencesOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.QueryOperationalIntentReferencesRequest], - Execute: ExecuteQueryOperationalIntentReferences, + Execute: executeQueryOperationalIntentReferences, IsReadOnly: true, } Registry[restapi.DeleteOperationalIntentReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.DeleteOperationalIntentReferenceRequest], - Execute: ExecuteDeleteOperationalIntentReference, + 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, } } @@ -68,9 +79,9 @@ func SubscriptionIsImplicitAndOnlyAttachedToOIR(ctx context.Context, r repos.Rep return false, nil } -// ExecuteDeleteOperationalIntentReference deletes a single operational intent ref for a given ID +// 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) { +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) @@ -171,7 +182,7 @@ func ExecuteDeleteOperationalIntentReference(ctx context.Context, repo repos.Rep }, nil } -func ExecuteGetOperationalIntentReference(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +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) @@ -199,7 +210,7 @@ func ExecuteGetOperationalIntentReference(ctx context.Context, repo repos.Reposi }, nil } -func ExecuteQueryOperationalIntentReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { +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) @@ -251,11 +262,11 @@ func CheckUpsertPermissionsAndReturnManager(authorizedManager *api.Authorization 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. +// 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( +func validateUpsertRequestAgainstPreviousOIR( requestingManager dssmodels.Manager, providedOVN scdmodels.OVN, previousOIR *scdmodels.OperationalIntent, @@ -281,11 +292,11 @@ func ValidateUpsertRequestAgainstPreviousOIR( return nil } -// ComputeNotificationVolume computes the volume that needs to be queried for subscriptions +// 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( +func computeNotificationVolume( previousOIR *scdmodels.OperationalIntent, requestedExtent *dssmodels.Volume4D) (*dssmodels.Volume4D, error) { @@ -330,7 +341,7 @@ type validOIRParams struct { Key map[scdmodels.OVN]bool } -func (vp *validOIRParams) ToOIR(manager dssmodels.Manager, attachedSub *scdmodels.Subscription, version scdmodels.VersionNumber, pastOVNs []scdmodels.OVN) *scdmodels.OperationalIntent { +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. @@ -475,9 +486,9 @@ func ValidateAndReturnOIRUpsertParams( return valid, nil } -// CreateAndStoreNewImplicitSubscription will create a brand new implicit subscription based on the provided parameters, +// 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) { +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") @@ -504,11 +515,11 @@ func CreateAndStoreNewImplicitSubscription(ctx context.Context, r repos.Reposito return r.UpsertSubscription(ctx, &subToUpsert) } -// ValidateKeyAndProvideConflictResponse ensures that the provided key contains all the necessary OVNs relevant for the area covered by the OperationalIntent. +// 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. +// - If keys are missing, the conflict response will be returned (conflict, nil). // - In case of any other error, (nil, error) will be returned. -func ValidateKeyAndProvideConflictResponse( +func validateKeyAndProvideConflictResponse( ctx context.Context, r repos.Repository, requestingManager dssmodels.Manager, @@ -578,16 +589,16 @@ func ValidateKeyAndProvideConflictResponse( } } - return responseConflict, stacktrace.NewErrorWithCode(dsserr.MissingOVNs, "Missing OVNs: %v", msg) + return responseConflict, nil } return nil, nil } -// EnsureSubscriptionCoversOIR ensures that the subscription covers the requested geo-temporal extent, extending it if both possible and required, +// 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) { +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) { @@ -624,3 +635,202 @@ func EnsureSubscriptionCoversOIR(ctx context.Context, r repos.Repository, sub *s 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, +// 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") + } + if auth.ClientID == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing manager") + } + manager := dssmodels.Manager(*auth.ClientID) + + // 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") + } + } + + if validParams.State.RequiresKey() { + responseConflict, err := validateKeyAndProvideConflictResponse(ctx, repo, manager, validParams, attachedSub) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to validate key") + } + if responseConflict != nil { + return &PutOperationalIntentReferenceResult{Conflict: responseConflict}, nil + } + } + + // 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/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/store.go b/pkg/scd/store/raftstore/store.go index 918499c25..efee3ad3c 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,18 @@ 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) 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 +65,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..405ae8df2 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -60,11 +60,10 @@ func TransactWithResult[R any, ResultType any](ctx context.Context, store Store[ if err != nil { return empty, err } - resultType, ok := transactionResult.(ResultType) - if !ok { - return empty, stacktrace.NewError("unexpected result type %T, want %T", transactionResult, empty) + if resultType, ok := transactionResult.(ResultType); ok { + return resultType, nil } - 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)) }) }