From f38fd15ea2ae9470a270c08ee640c88981d14c92 Mon Sep 17 00:00:00 2001 From: Mariem Baccari Date: Wed, 19 Aug 2026 17:14:11 +0200 Subject: [PATCH] [raft/scd] Extract update and create constraint --- pkg/scd/actions/constraint.go | 167 +++++++++++++++++++++++++++++++ pkg/scd/constraints_handler.go | 178 +++++---------------------------- 2 files changed, 190 insertions(+), 155 deletions(-) diff --git a/pkg/scd/actions/constraint.go b/pkg/scd/actions/constraint.go index 702c48c69..262be05f1 100644 --- a/pkg/scd/actions/constraint.go +++ b/pkg/scd/actions/constraint.go @@ -2,6 +2,7 @@ package actions import ( "context" + "time" "github.com/golang/geo/s2" restapi "github.com/interuss/dss/pkg/api/scdv1" @@ -10,6 +11,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" "github.com/jackc/pgx/v5" ) @@ -26,6 +28,16 @@ func init() { Execute: ExecuteGetConstraint, IsReadOnly: true, } + Registry[restapi.CreateConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.CreateConstraintReferenceRequest], + Execute: ExecutePutConstraint, + } + Registry[restapi.UpdateConstraintReferenceOperationID] = dssstore.OperationHandler[repos.Repository]{ + Encode: dssstore.EncodeJSON, + Decode: dssstore.DecodeJSON[*restapi.UpdateConstraintReferenceRequest], + Execute: ExecutePutConstraint, + } Registry[restapi.QueryConstraintReferencesOperationID] = dssstore.OperationHandler[repos.Repository]{ Encode: dssstore.EncodeJSON, Decode: dssstore.DecodeJSON[*restapi.QueryConstraintReferencesRequest], @@ -63,6 +75,161 @@ func ExecuteGetConstraint(ctx context.Context, repo repos.Repository, request ds }, nil } +// 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) { + var ( + manager string + entityid restapi.EntityID + ovn restapi.EntityOVN + params *restapi.PutConstraintReferenceParameters + ) + + switch req := request.(type) { + case *restapi.CreateConstraintReferenceRequest: + manager, entityid, params = *req.Auth.ClientID, req.Entityid, req.Body + case *restapi.UpdateConstraintReferenceRequest: + manager, entityid, ovn, params = *req.Auth.ClientID, req.Entityid, req.Ovn, req.Body + default: + return nil, stacktrace.NewError("unexpected request type %T for operation %q", request, restapi.CreateConstraintReferenceOperationID) + } + + validParams, err := ValidateAndReturnConstraintUpsertParams(timestamp.MustGetRequestTimestamp(ctx), entityid, params) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Constraint upsert parameters") + } + + version := scdmodels.VersionNumber(1) + + // Get existing Constraint, if any, and validate request + old, err := repo.GetConstraint(ctx, validParams.id) + switch { + case err == pgx.ErrNoRows: + // No existing Constraint; verify that creation was requested + if ovn != "" { + return nil, stacktrace.NewErrorWithCode(dsserr.VersionMismatch, "Old version %s does not exist", ovn) + } + case err != nil: + return nil, stacktrace.Propagate(err, "Could not get Constraint from repo") + } + if old != nil { + if old.Manager != dssmodels.Manager(manager) { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, + "Constraint owned by %s, but %s attempted to modify", old.Manager, manager) + } + if old.OVN != scdmodels.OVN(ovn) { + return nil, stacktrace.NewErrorWithCode(dsserr.VersionMismatch, + "Current version is %s but client specified version %s", old.OVN, ovn) + } + version = old.Version + 1 + } + + // Compute total affected Volume4D for notification purposes + var notifyVol4 *dssmodels.Volume4D + if old == nil { + notifyVol4 = validParams.uExtent + } else { + oldVol4 := &dssmodels.Volume4D{ + StartTime: old.StartTime, + EndTime: old.EndTime, + SpatialVolume: &dssmodels.Volume3D{ + AltitudeHi: old.AltitudeUpper, + AltitudeLo: old.AltitudeLower, + Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { + return old.Cells, nil + }), + }} + notifyVol4, err = dssmodels.UnionVolumes4D(validParams.uExtent, oldVol4) + if err != nil { + return nil, stacktrace.Propagate(err, "Error constructing 4D volumes union") + } + } + + // Construct the new Constraint + constraint := validParams.toConstraint(dssmodels.Manager(manager), version) + + // Upsert the Constraint + constraint, err = repo.UpsertConstraint(ctx, constraint) + if err != nil { + return nil, err + } + + // Find the Subscriptions interested in Constraints and increment their + // notification indices. + subs, err := repo.IncrementNotificationIndicesForConstraints(ctx, notifyVol4) + if err != nil { + return nil, err + } + + // Return response to client + return &restapi.ChangeConstraintReferenceResponse{ + ConstraintReference: *constraint.ToRest(), + Subscribers: makeSubscribersToNotify(subs), + }, nil +} + +type validConstraintParams struct { + id dssmodels.ID + uExtent *dssmodels.Volume4D + cells s2.CellUnion + ussBaseURL string +} + +func (vp *validConstraintParams) toConstraint(manager dssmodels.Manager, version scdmodels.VersionNumber) *scdmodels.Constraint { + return &scdmodels.Constraint{ + ID: vp.id, + Manager: manager, + Version: version, + + StartTime: vp.uExtent.StartTime, + EndTime: vp.uExtent.EndTime, + AltitudeLower: vp.uExtent.SpatialVolume.AltitudeLo, + AltitudeUpper: vp.uExtent.SpatialVolume.AltitudeHi, + + USSBaseURL: vp.ussBaseURL, + Cells: vp.cells, + } +} + +// ValidateAndReturnConstraintUpsertParams performs validation of Constraint upsert requests and returns a validConstraintParams struct if successful. +// Note that this does NOT check for anything related to access controls: any error returned should be labeled as a dsserr.BadRequest. +func ValidateAndReturnConstraintUpsertParams( + now time.Time, + entityid restapi.EntityID, + params *restapi.PutConstraintReferenceParameters, +) (*validConstraintParams, error) { + var err error + valid := &validConstraintParams{} + valid.id, err = dssmodels.IDFromString(string(entityid)) + if err != nil { + return nil, stacktrace.NewError("Invalid ID format: `%s`", entityid) + } + + if len(params.UssBaseUrl) == 0 { + return nil, stacktrace.NewError("Missing required UssBaseUrl") + } + + valid.ussBaseURL = string(params.UssBaseUrl) + + // Start and end times are required for each volume + // The end time may not be in the past + valid.uExtent, err = scdmodels.UnionVolumes4DFromSCDRest( + params.Extents, + scdmodels.WithRequireTimeBounds(), + scdmodels.WithRequireEndTimeAfter(now), + ) + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid extents") + } + + valid.cells, err = valid.uExtent.CalculateSpatialCovering() + if err != nil { + return nil, stacktrace.Propagate(err, "Invalid area") + } + + return valid, nil +} + func ExecuteQueryConstraintReferences(ctx context.Context, repo repos.Repository, request dssstore.OperationRequest) (any, error) { req, ok := request.(*restapi.QueryConstraintReferencesRequest) if !ok { diff --git a/pkg/scd/constraints_handler.go b/pkg/scd/constraints_handler.go index 24db03e60..055502e72 100644 --- a/pkg/scd/constraints_handler.go +++ b/pkg/scd/constraints_handler.go @@ -2,18 +2,17 @@ package scd import ( "context" - "time" - "github.com/golang/geo/s2" "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/repos" dssstore "github.com/interuss/dss/pkg/store" + "github.com/interuss/dss/pkg/timestamp" "github.com/interuss/stacktrace" - "github.com/jackc/pgx/v5" ) // DeleteConstraintReference deletes a single constraint ref for a given ID at @@ -102,7 +101,13 @@ func (a *Server) CreateConstraintReference(ctx context.Context, req *restapi.Cre Message: dsserr.Handle(ctx, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing manager"))}} } - res, err := a.PutConstraintReference(ctx, *req.Auth.ClientID, req.Entityid, "", req.Body) + err := validateConstraintUpsertRequest(ctx, req.Entityid, req.Body, a.AllowHTTPBaseUrls) + if err != nil { + return restapi.CreateConstraintReferenceResponseSet{Response400: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Constraint upsert parameters"))}} + } + + res, err := dssstore.TransactWithResult[repos.Repository, *restapi.ChangeConstraintReferenceResponse](ctx, a.Store, req) if err != nil { err = stacktrace.Propagate(err, "Could not put constraint") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} @@ -134,7 +139,13 @@ func (a *Server) UpdateConstraintReference(ctx context.Context, req *restapi.Upd Message: dsserr.Handle(ctx, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing manager"))}} } - res, err := a.PutConstraintReference(ctx, *req.Auth.ClientID, req.Entityid, req.Ovn, req.Body) + err := validateConstraintUpsertRequest(ctx, req.Entityid, req.Body, a.AllowHTTPBaseUrls) + if err != nil { + return restapi.UpdateConstraintReferenceResponseSet{Response400: &restapi.ErrorResponse{ + Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Constraint upsert parameters"))}} + } + + res, err := dssstore.TransactWithResult[repos.Repository, *restapi.ChangeConstraintReferenceResponse](ctx, a.Store, req) if err != nil { err = stacktrace.Propagate(err, "Could not put constraint") errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)} @@ -154,165 +165,22 @@ func (a *Server) UpdateConstraintReference(ctx context.Context, req *restapi.Upd return restapi.UpdateConstraintReferenceResponseSet{Response200: res} } -// PutConstraintReference inserts or updates a Constraint. -// If the ovn argument is empty (""), it will attempt to create a new Constraint. -func (a *Server) PutConstraintReference(ctx context.Context, manager string, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutConstraintReferenceParameters, -) (*restapi.ChangeConstraintReferenceResponse, error) { - validParams, err := validateAndReturnConstraintUpsertParams(time.Now(), entityid, params, a.AllowHTTPBaseUrls) +// 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) if err != nil { - return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Constraint upsert parameters") - } - - var response *restapi.ChangeConstraintReferenceResponse - action := func(ctx context.Context, r repos.Repository) (err error) { - version := scdmodels.VersionNumber(1) - - // Get existing Constraint, if any, and validate request - old, err := r.GetConstraint(ctx, validParams.id) - switch { - case err == pgx.ErrNoRows: - // No existing Constraint; verify that creation was requested - if ovn != "" { - return stacktrace.NewErrorWithCode(dsserr.VersionMismatch, "Old version %s does not exist", ovn) - } - case err != nil: - return stacktrace.Propagate(err, "Could not get Constraint from repo") - } - if old != nil { - if old.Manager != dssmodels.Manager(manager) { - return stacktrace.NewErrorWithCode(dsserr.PermissionDenied, - "Constraint owned by %s, but %s attempted to modify", old.Manager, manager) - } - if old.OVN != scdmodels.OVN(ovn) { - return stacktrace.NewErrorWithCode(dsserr.VersionMismatch, - "Current version is %s but client specified version %s", old.OVN, ovn) - } - version = old.Version + 1 - } - - // Compute total affected Volume4D for notification purposes - var notifyVol4 *dssmodels.Volume4D - if old == nil { - notifyVol4 = validParams.uExtent - } else { - oldVol4 := &dssmodels.Volume4D{ - StartTime: old.StartTime, - EndTime: old.EndTime, - SpatialVolume: &dssmodels.Volume3D{ - AltitudeHi: old.AltitudeUpper, - AltitudeLo: old.AltitudeLower, - Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { - return old.Cells, nil - }), - }} - notifyVol4, err = dssmodels.UnionVolumes4D(validParams.uExtent, oldVol4) - if err != nil { - return stacktrace.Propagate(err, "Error constructing 4D volumes union") - } - } - - // Construct the new Constraint - constraint := validParams.toConstraint(dssmodels.Manager(manager), version) - - // Upsert the Constraint - constraint, err = r.UpsertConstraint(ctx, constraint) - if err != nil { - return err - } - - // Find the Subscriptions interested in Constraints and increment their - // notification indices. - subs, err := r.IncrementNotificationIndicesForConstraints(ctx, notifyVol4) - if err != nil { - return err - } - - // Return response to client - response = &restapi.ChangeConstraintReferenceResponse{ - ConstraintReference: *constraint.ToRest(), - Subscribers: makeSubscribersToNotify(subs), - } - - return nil + return err } - _, err = a.Store.Transact(ctx, dssstore.NewFuncOperation(action)) - if err != nil { - return nil, err // No need to Propagate this error as this is not a useful stacktrace line - } - - return response, nil -} - -type validConstraintParams struct { - id dssmodels.ID - uExtent *dssmodels.Volume4D - cells s2.CellUnion - ussBaseURL string -} - -func (vp *validConstraintParams) toConstraint(manager dssmodels.Manager, version scdmodels.VersionNumber) *scdmodels.Constraint { - return &scdmodels.Constraint{ - ID: vp.id, - Manager: manager, - Version: version, - - StartTime: vp.uExtent.StartTime, - EndTime: vp.uExtent.EndTime, - AltitudeLower: vp.uExtent.SpatialVolume.AltitudeLo, - AltitudeUpper: vp.uExtent.SpatialVolume.AltitudeHi, - - USSBaseURL: vp.ussBaseURL, - Cells: vp.cells, - } -} - -// validateAndReturnConstraintUpsertParams checks that the parameters for an Constraint Reference upsert are valid. -// Note that this does NOT check for anything related to access controls: any error returned should be labeled -// as a dsserr.BadRequest. -func validateAndReturnConstraintUpsertParams( - now time.Time, - entityid restapi.EntityID, - params *restapi.PutConstraintReferenceParameters, - allowHTTPBaseUrls bool, -) (*validConstraintParams, error) { - valid := &validConstraintParams{} - var err error - - valid.id, err = dssmodels.IDFromString(string(entityid)) - if err != nil { - return nil, stacktrace.NewError("Invalid ID format: `%s`", entityid) - } - - if len(params.UssBaseUrl) == 0 { - return nil, stacktrace.NewError("Missing required UssBaseUrl") - } - valid.ussBaseURL = string(params.UssBaseUrl) - if !allowHTTPBaseUrls { err = scdmodels.ValidateUSSBaseURL(string(params.UssBaseUrl)) if err != nil { - return nil, stacktrace.Propagate(err, "Failed to validate base URL") + return stacktrace.Propagate(err, "Failed to validate base URL") } } - // Start and end times are required for each volume - // The end time may not be in the past - valid.uExtent, err = scdmodels.UnionVolumes4DFromSCDRest( - params.Extents, - scdmodels.WithRequireTimeBounds(), - scdmodels.WithRequireEndTimeAfter(now), - ) - if err != nil { - return nil, stacktrace.Propagate(err, "Invalid extents") - } - - valid.cells, err = valid.uExtent.CalculateSpatialCovering() - if err != nil { - return nil, stacktrace.Propagate(err, "Invalid area") - } - - return valid, nil + return nil } // QueryConstraintReferences queries existing contraint refs in the given