diff --git a/pkg/memstore/utils/utils.go b/pkg/memstore/utils/utils.go new file mode 100644 index 000000000..135b8325e --- /dev/null +++ b/pkg/memstore/utils/utils.go @@ -0,0 +1,8 @@ +package utils + +func ClonePtr[T any](v *T) *T { + if v == nil { + return nil + } + return new(*v) +} diff --git a/pkg/rid/store/memstore/identification_service_area.go b/pkg/rid/store/memstore/identification_service_area.go index b7f32bdf9..bf7bb042b 100644 --- a/pkg/rid/store/memstore/identification_service_area.go +++ b/pkg/rid/store/memstore/identification_service_area.go @@ -7,6 +7,7 @@ import ( "github.com/golang/geo/s2" dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" ridmodels "github.com/interuss/dss/pkg/rid/models" "github.com/interuss/dss/pkg/timestamp" @@ -19,10 +20,10 @@ func isaRecordFromModel(isa *ridmodels.IdentificationServiceArea, updatedAt time URL: isa.URL, Owner: isa.Owner, Cells: slices.Clone(isa.Cells), - StartTime: clonePtr(isa.StartTime), - EndTime: clonePtr(isa.EndTime), - AltitudeHi: clonePtr(isa.AltitudeHi), - AltitudeLo: clonePtr(isa.AltitudeLo), + StartTime: utils.ClonePtr(isa.StartTime), + EndTime: utils.ClonePtr(isa.EndTime), + AltitudeHi: utils.ClonePtr(isa.AltitudeHi), + AltitudeLo: utils.ClonePtr(isa.AltitudeLo), Writer: isa.Writer, UpdatedAt: updatedAt, } @@ -35,11 +36,11 @@ func (rec *isaRecord) toModel() *ridmodels.IdentificationServiceArea { URL: rec.URL, Owner: rec.Owner, Cells: slices.Clone(rec.Cells), - StartTime: clonePtr(rec.StartTime), - EndTime: clonePtr(rec.EndTime), + StartTime: utils.ClonePtr(rec.StartTime), + EndTime: utils.ClonePtr(rec.EndTime), Version: dssmodels.VersionFromTime(rec.UpdatedAt), - AltitudeHi: clonePtr(rec.AltitudeHi), - AltitudeLo: clonePtr(rec.AltitudeLo), + AltitudeHi: utils.ClonePtr(rec.AltitudeHi), + AltitudeLo: utils.ClonePtr(rec.AltitudeLo), Writer: rec.Writer, } } diff --git a/pkg/rid/store/memstore/store.go b/pkg/rid/store/memstore/store.go index 531d92c3f..6982daed9 100644 --- a/pkg/rid/store/memstore/store.go +++ b/pkg/rid/store/memstore/store.go @@ -8,6 +8,7 @@ import ( "github.com/golang/geo/s2" "github.com/interuss/dss/pkg/geo" "github.com/interuss/dss/pkg/memstore" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" "github.com/interuss/dss/pkg/rid/repos" "github.com/interuss/stacktrace" @@ -117,13 +118,6 @@ func overlaps(cells s2.CellUnion, set map[s2.CellID]struct{}) bool { return false } -func clonePtr[T any](v *T) *T { - if v == nil { - return nil - } - return new(*v) -} - type versionedRecord interface { version() *dssmodels.Version } @@ -182,20 +176,20 @@ func listExpired[M any, R expiringRecord[M]](store map[dssmodels.ID]R, writer st func (rec *isaRecord) clone() *isaRecord { cp := *rec cp.Cells = slices.Clone(rec.Cells) - cp.StartTime = clonePtr(rec.StartTime) - cp.EndTime = clonePtr(rec.EndTime) - cp.AltitudeHi = clonePtr(rec.AltitudeHi) - cp.AltitudeLo = clonePtr(rec.AltitudeLo) + cp.StartTime = utils.ClonePtr(rec.StartTime) + cp.EndTime = utils.ClonePtr(rec.EndTime) + cp.AltitudeHi = utils.ClonePtr(rec.AltitudeHi) + cp.AltitudeLo = utils.ClonePtr(rec.AltitudeLo) return &cp } func (rec *subscriptionRecord) clone() *subscriptionRecord { cp := *rec cp.Cells = slices.Clone(rec.Cells) - cp.StartTime = clonePtr(rec.StartTime) - cp.EndTime = clonePtr(rec.EndTime) - cp.AltitudeHi = clonePtr(rec.AltitudeHi) - cp.AltitudeLo = clonePtr(rec.AltitudeLo) + cp.StartTime = utils.ClonePtr(rec.StartTime) + cp.EndTime = utils.ClonePtr(rec.EndTime) + cp.AltitudeHi = utils.ClonePtr(rec.AltitudeHi) + cp.AltitudeLo = utils.ClonePtr(rec.AltitudeLo) return &cp } diff --git a/pkg/rid/store/memstore/subscriptions.go b/pkg/rid/store/memstore/subscriptions.go index fa5d43d26..63cb22c09 100644 --- a/pkg/rid/store/memstore/subscriptions.go +++ b/pkg/rid/store/memstore/subscriptions.go @@ -1,5 +1,8 @@ package memstore +// Note: as of now, doesn't implement timeBasedNotificationIndex settings, as it doesn't improve performance +// and was done mostly for SQL store improvements. + import ( "context" "iter" @@ -9,6 +12,7 @@ import ( "github.com/golang/geo/s2" dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" ridmodels "github.com/interuss/dss/pkg/rid/models" "github.com/interuss/dss/pkg/timestamp" @@ -22,10 +26,10 @@ func subRecordFromModel(s *ridmodels.Subscription, updatedAt time.Time) *subscri NotificationIndex: s.NotificationIndex, Owner: s.Owner, Cells: slices.Clone(s.Cells), - StartTime: clonePtr(s.StartTime), - EndTime: clonePtr(s.EndTime), - AltitudeHi: clonePtr(s.AltitudeHi), - AltitudeLo: clonePtr(s.AltitudeLo), + StartTime: utils.ClonePtr(s.StartTime), + EndTime: utils.ClonePtr(s.EndTime), + AltitudeHi: utils.ClonePtr(s.AltitudeHi), // TODO: As noted during review, altitudes seems unused. + AltitudeLo: utils.ClonePtr(s.AltitudeLo), Writer: s.Writer, UpdatedAt: updatedAt, } @@ -38,11 +42,11 @@ func (rec *subscriptionRecord) toModel() *ridmodels.Subscription { NotificationIndex: rec.NotificationIndex, Owner: rec.Owner, Cells: slices.Clone(rec.Cells), - StartTime: clonePtr(rec.StartTime), - EndTime: clonePtr(rec.EndTime), + StartTime: utils.ClonePtr(rec.StartTime), + EndTime: utils.ClonePtr(rec.EndTime), Version: dssmodels.VersionFromTime(rec.UpdatedAt), - AltitudeHi: clonePtr(rec.AltitudeHi), - AltitudeLo: clonePtr(rec.AltitudeLo), + AltitudeHi: utils.ClonePtr(rec.AltitudeHi), + AltitudeLo: utils.ClonePtr(rec.AltitudeLo), Writer: rec.Writer, } } diff --git a/pkg/scd/store/memstore/availability.go b/pkg/scd/store/memstore/availability.go index 3579672e1..bb750996a 100644 --- a/pkg/scd/store/memstore/availability.go +++ b/pkg/scd/store/memstore/availability.go @@ -3,16 +3,36 @@ package memstore import ( "context" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" scdmodels "github.com/interuss/dss/pkg/scd/models" - "github.com/interuss/stacktrace" + "github.com/interuss/dss/pkg/timestamp" + "github.com/jackc/pgx/v5" ) +func (rec *availabilityRecord) toModel() *scdmodels.UssAvailabilityStatus { + return &scdmodels.UssAvailabilityStatus{ + Uss: rec.Uss, + Availability: rec.Availability, + Version: scdmodels.NewOVNFromTime(rec.UpdatedAt, rec.Uss.String()), + } +} + func (r *repo) GetUssAvailability(_ context.Context, id dssmodels.Manager) (*scdmodels.UssAvailabilityStatus, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetUssAvailability not implemented for memstore") + rec, ok := r.state.Availabilities[id] + if !ok { + return nil, pgx.ErrNoRows // TODO: #1608 + } + return rec.toModel(), nil } -func (r *repo) UpsertUssAvailability(_ context.Context, ussa *scdmodels.UssAvailabilityStatus) (*scdmodels.UssAvailabilityStatus, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertUssAvailability not implemented for memstore") +func (r *repo) UpsertUssAvailability(ctx context.Context, s *scdmodels.UssAvailabilityStatus) (*scdmodels.UssAvailabilityStatus, error) { + now := timestamp.MustGetRequestTimestamp(ctx) + + rec := &availabilityRecord{ + Uss: s.Uss, + Availability: s.Availability, + UpdatedAt: now, + } + r.state.Availabilities[s.Uss] = rec + return rec.toModel(), nil } diff --git a/pkg/scd/store/memstore/availability_test.go b/pkg/scd/store/memstore/availability_test.go new file mode 100644 index 000000000..91ca48fb0 --- /dev/null +++ b/pkg/scd/store/memstore/availability_test.go @@ -0,0 +1,31 @@ +package memstore + +import ( + "testing" + + scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func TestUssAvailabilityUpsertGet(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + got, err := r.UpsertUssAvailability(ctx, sampleAvailability()) + require.NoError(t, err) + require.Equal(t, manager, got.Uss) + require.Equal(t, scdmodels.UssAvailabilityStateNormal, got.Availability) + require.Equal(t, scdmodels.OVN("HXjEfPAc0lkinCf0ejtSGiGPE4o2Qogm-iXAGPG-QNo_"), got.Version) + + fetched, err := r.GetUssAvailability(ctx, manager) + require.NoError(t, err) + require.Equal(t, got.Version, fetched.Version) + require.Equal(t, scdmodels.UssAvailabilityStateNormal, fetched.Availability) +} + +func TestGetUssAvailabilityMissingReturnsErrNoRows(t *testing.T) { + r := setUpStore(t) + _, err := r.GetUssAvailability(writeCtx(), manager) + require.ErrorIs(t, err, pgx.ErrNoRows) +} diff --git a/pkg/scd/store/memstore/constraints.go b/pkg/scd/store/memstore/constraints.go index be3e46eee..6fb54f143 100644 --- a/pkg/scd/store/memstore/constraints.go +++ b/pkg/scd/store/memstore/constraints.go @@ -2,29 +2,97 @@ package memstore import ( "context" + "slices" - dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" scdmodels "github.com/interuss/dss/pkg/scd/models" + dsssql "github.com/interuss/dss/pkg/sql" + "github.com/interuss/dss/pkg/timestamp" "github.com/interuss/stacktrace" + "github.com/jackc/pgx/v5" ) +func (rec *constraintRecord) toModel() *scdmodels.Constraint { + return &scdmodels.Constraint{ + ID: rec.ID, + Manager: rec.Manager, + Version: rec.Version, + OVN: scdmodels.NewOVNFromTime(rec.UpdatedAt, rec.ID.String()), + StartTime: utils.ClonePtr(rec.StartTime), + EndTime: utils.ClonePtr(rec.EndTime), + USSBaseURL: rec.USSBaseURL, + AltitudeLower: utils.ClonePtr(rec.AltitudeLower), + AltitudeUpper: utils.ClonePtr(rec.AltitudeUpper), + Cells: slices.Clone(rec.Cells), + } +} + func (r *repo) SearchConstraints(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchConstraints not implemented for memstore") + want, err := coveringSet(v4d) + if err != nil { + return nil, err + } + if len(want) == 0 { + return []*scdmodels.Constraint{}, nil + } + + var out []*scdmodels.Constraint + for _, rec := range r.state.Constraints { + if !overlaps(rec.Cells, want) { + continue + } + if !overlapsTime(rec.StartTime, rec.EndTime, v4d) { + continue + } + out = append(out, rec.toModel()) + + if len(out) >= dssmodels.MaxResultLimit { // mirror SQL "LIMIT MaxResultLimit" + break + } + } + return out, nil } func (r *repo) GetConstraint(_ context.Context, id dssmodels.ID) (*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetConstraint not implemented for memstore") + rec, ok := r.state.Constraints[id] + if !ok { + return nil, pgx.ErrNoRows // TODO: #1608 + } + return rec.toModel(), nil } -func (r *repo) UpsertConstraint(_ context.Context, constraint *scdmodels.Constraint) (*scdmodels.Constraint, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertConstraint not implemented for memstore") +func (r *repo) UpsertConstraint(ctx context.Context, s *scdmodels.Constraint) (*scdmodels.Constraint, error) { + if _, err := dsssql.CellUnionToCellIdsWithValidation(s.Cells); err != nil { + return nil, stacktrace.Propagate(err, "Failed to convert array to jackc/pgtype") + } + + now := timestamp.MustGetRequestTimestamp(ctx) + + rec := &constraintRecord{ + ID: s.ID, + Manager: s.Manager, + Version: s.Version, + StartTime: utils.ClonePtr(s.StartTime), + EndTime: utils.ClonePtr(s.EndTime), + USSBaseURL: s.USSBaseURL, + AltitudeLower: utils.ClonePtr(s.AltitudeLower), + AltitudeUpper: utils.ClonePtr(s.AltitudeUpper), + Cells: slices.Clone(s.Cells), + UpdatedAt: now, + } + r.state.Constraints[s.ID] = rec + return rec.toModel(), nil } func (r *repo) DeleteConstraint(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteConstraint not implemented for memstore") + if _, ok := r.state.Constraints[id]; !ok { + return pgx.ErrNoRows // TODO: #1608 + } + delete(r.state.Constraints, id) + return nil } func (r *repo) CountConstraints(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountConstraint not implemented for memstore") + return int64(len(r.state.Constraints)), nil } diff --git a/pkg/scd/store/memstore/constraints_test.go b/pkg/scd/store/memstore/constraints_test.go new file mode 100644 index 000000000..6f98fcf5a --- /dev/null +++ b/pkg/scd/store/memstore/constraints_test.go @@ -0,0 +1,73 @@ +package memstore + +import ( + "testing" + "time" + + "github.com/golang/geo/s2" + "github.com/interuss/dss/pkg/scd/models" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func TestConstraintUpsertGetDelete(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + got, err := r.UpsertConstraint(ctx, sampleConstraint()) + require.NoError(t, err) + require.Equal(t, constraintId, got.ID) + require.Equal(t, manager, got.Manager) + require.Equal(t, models.OVN("4Ne9uzrR5K9LYEyJ-c6rTI0r-FTuLQGMSBR1j.SaTvk_"), got.OVN) + + fetched, err := r.GetConstraint(ctx, constraintId) + require.NoError(t, err) + require.Equal(t, got.OVN, fetched.OVN) + require.Equal(t, cells, fetched.Cells) + + count, err := r.CountConstraints(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + require.NoError(t, r.DeleteConstraint(ctx, constraintId)) + + _, err = r.GetConstraint(ctx, constraintId) + require.ErrorIs(t, err, pgx.ErrNoRows) +} + +func TestConstraintGetMissingReturnsErrNoRows(t *testing.T) { + r := setUpStore(t) + _, err := r.GetConstraint(writeCtx(), constraintId) + require.ErrorIs(t, err, pgx.ErrNoRows) +} + +func TestConstraintDeleteMissingReturnsErrNoRows(t *testing.T) { + r := setUpStore(t) + err := r.DeleteConstraint(writeCtx(), constraintId) + require.ErrorIs(t, err, pgx.ErrNoRows) +} + +func TestSearchConstraints(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + _, err := r.UpsertConstraint(ctx, sampleConstraint()) + require.NoError(t, err) + + // Overlapping volume with no time bounds matches. + res, err := r.SearchConstraints(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, res, 1) + + // Time window after the constraint's end excludes it. + afterStart := endTime.Add(time.Hour) + afterEnd := afterStart.Add(time.Hour) + res, err = r.SearchConstraints(ctx, volume4D(cells, &afterStart, &afterEnd, nil, nil)) + require.NoError(t, err) + require.Empty(t, res) + + // No covering cells returns an empty (non-nil) slice. + res, err = r.SearchConstraints(ctx, volume4D(s2.CellUnion{}, nil, nil, nil, nil)) + require.NoError(t, err) + require.NotNil(t, res) + require.Empty(t, res) +} diff --git a/pkg/scd/store/memstore/operational_intents.go b/pkg/scd/store/memstore/operational_intents.go index b39f81793..fad0deec2 100644 --- a/pkg/scd/store/memstore/operational_intents.go +++ b/pkg/scd/store/memstore/operational_intents.go @@ -2,38 +2,181 @@ package memstore import ( "context" + "errors" + "slices" "time" dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/timestamp" "github.com/interuss/stacktrace" + "github.com/jackc/pgx/v5" ) -func (r *repo) GetOperationalIntent(_ context.Context, id dssmodels.ID) (*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetOperationalIntent not implemented for memstore") +// toModel rebuilds the OperationalIntent model without its UssAvailability, +// which is attached separately (see buildOperationalIntents). +func (rec *operationalIntentRecord) toModel() *scdmodels.OperationalIntent { + // If the managing USS has requested a specific OVN it is persisted, otherwise + // a default DSS-generated OVN based on the last update time is used. + var ovn scdmodels.OVN + if rec.USSRequestedOVN != "" { + ovn = scdmodels.OVN(rec.USSRequestedOVN) + } else { + ovn = scdmodels.NewOVNFromTime(rec.UpdatedAt, rec.ID.String()) + } + return &scdmodels.OperationalIntent{ + ID: rec.ID, + Manager: rec.Manager, + Version: rec.Version, + State: rec.State, + OVN: ovn, + PastOVNs: slices.Clone(rec.PastOVNs), + StartTime: utils.ClonePtr(rec.StartTime), + EndTime: utils.ClonePtr(rec.EndTime), + USSBaseURL: rec.USSBaseURL, + SubscriptionID: utils.ClonePtr(rec.SubscriptionID), + AltitudeLower: utils.ClonePtr(rec.AltitudeLower), + AltitudeUpper: utils.ClonePtr(rec.AltitudeUpper), + Cells: slices.Clone(rec.Cells), + } +} + +// buildOperationalIntents converts records to models and attaches the +// UssAvailability of each managing USS +func (r *repo) buildOperationalIntents(ctx context.Context, recs []*operationalIntentRecord) ([]*scdmodels.OperationalIntent, error) { + ussAvailabilities := map[dssmodels.Manager]scdmodels.UssAvailabilityState{} + payload := make([]*scdmodels.OperationalIntent, 0, len(recs)) + for _, rec := range recs { + o := rec.toModel() + ussAvailabilities[o.Manager] = scdmodels.UssAvailabilityStateUnknown + payload = append(payload, o) + } + + for manager := range ussAvailabilities { + ussAvailability, err := r.GetUssAvailability(ctx, manager) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, stacktrace.Propagate(err, "Error getting USS availability of %s", manager) + } + if ussAvailability != nil { + ussAvailabilities[manager] = ussAvailability.Availability + } + } + + for _, op := range payload { + op.UssAvailability = ussAvailabilities[op.Manager] + } + return payload, nil +} + +func (r *repo) GetOperationalIntent(ctx context.Context, id dssmodels.ID) (*scdmodels.OperationalIntent, error) { + rec, ok := r.state.OperationalIntents[id] + if !ok { + return nil, nil + } + built, err := r.buildOperationalIntents(ctx, []*operationalIntentRecord{rec}) + if err != nil { + return nil, err + } + return built[0], nil } func (r *repo) DeleteOperationalIntent(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteOperationalIntent not implemented for memstore") + if _, ok := r.state.OperationalIntents[id]; !ok { + return stacktrace.NewError("Could not delete Operation that does not exist") + } + delete(r.state.OperationalIntents, id) + return nil } -func (r *repo) UpsertOperationalIntent(_ context.Context, operation *scdmodels.OperationalIntent) (*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertOperationalIntent not implemented for memstore") +func (r *repo) UpsertOperationalIntent(ctx context.Context, operation *scdmodels.OperationalIntent) (*scdmodels.OperationalIntent, error) { + // An empty OVN means the DSS generates it; it is persisted as NULL in the + // sqlstore (represented here by an empty USSRequestedOVN). + var ussRequestedOVN string + if operation.OVN != "" { + ussRequestedOVN = operation.OVN.String() + } + + now := timestamp.MustGetRequestTimestamp(ctx) + + rec := &operationalIntentRecord{ + ID: operation.ID, + Manager: operation.Manager, + Version: operation.Version, + State: operation.State, + StartTime: utils.ClonePtr(operation.StartTime), + EndTime: utils.ClonePtr(operation.EndTime), + USSBaseURL: operation.USSBaseURL, + SubscriptionID: utils.ClonePtr(operation.SubscriptionID), + AltitudeLower: utils.ClonePtr(operation.AltitudeLower), + AltitudeUpper: utils.ClonePtr(operation.AltitudeUpper), + Cells: slices.Clone(operation.Cells), + USSRequestedOVN: ussRequestedOVN, + PastOVNs: slices.Clone(operation.PastOVNs), + UpdatedAt: now, + } + r.state.OperationalIntents[operation.ID] = rec + + built, err := r.buildOperationalIntents(ctx, []*operationalIntentRecord{rec}) + if err != nil { + return nil, err + } + return built[0], nil } -func (r *repo) SearchOperationalIntents(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchOperationalIntents not implemented for memstore") +func (r *repo) SearchOperationalIntents(ctx context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.OperationalIntent, error) { + if v4d.SpatialVolume == nil || v4d.SpatialVolume.Footprint == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing geospatial footprint for query") + } + cells, err := v4d.SpatialVolume.Footprint.CalculateCovering() + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to calculate footprint covering") + } + if len(cells) == 0 { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing cell IDs for query") + } + + want := cellSet(cells) + var matched []*operationalIntentRecord + for _, rec := range r.state.OperationalIntents { + if !overlaps(rec.Cells, want) { + continue + } + // COALESCE(altitude_upper >= $2, true) with $2 = SpatialVolume.AltitudeLo + if rec.AltitudeUpper != nil && v4d.SpatialVolume.AltitudeLo != nil && *rec.AltitudeUpper < *v4d.SpatialVolume.AltitudeLo { + continue + } + // COALESCE(altitude_lower <= $3, true) with $3 = SpatialVolume.AltitudeHi + if rec.AltitudeLower != nil && v4d.SpatialVolume.AltitudeHi != nil && *rec.AltitudeLower > *v4d.SpatialVolume.AltitudeHi { + continue + } + if !overlapsTime(rec.StartTime, rec.EndTime, v4d) { + continue + } + matched = append(matched, rec) + + if len(matched) >= dssmodels.MaxResultLimit { // mirror SQL "LIMIT MaxResultLimit" + break + } + } + return r.buildOperationalIntents(ctx, matched) } func (r *repo) GetDependentOperationalIntents(_ context.Context, subscriptionID dssmodels.ID) ([]dssmodels.ID, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetDependentOperationalIntents not implemented for memstore") + var dependentOps []dssmodels.ID + for _, rec := range r.state.OperationalIntents { + if rec.SubscriptionID != nil && *rec.SubscriptionID == subscriptionID { + dependentOps = append(dependentOps, rec.ID) + } + } + return dependentOps, nil } -func (r *repo) ListExpiredOperationalIntents(_ context.Context, threshold time.Time) ([]*scdmodels.OperationalIntent, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "ListExpiredOperationalIntents not implemented for memstore") +func (r *repo) ListExpiredOperationalIntents(ctx context.Context, threshold time.Time) ([]*scdmodels.OperationalIntent, error) { + return r.buildOperationalIntents(ctx, listExpired(r.state.OperationalIntents, threshold, dssmodels.MaxResultLimit)) } func (r *repo) CountOperationalIntents(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountOperationalIntents not implemented for memstore") + return int64(len(r.state.OperationalIntents)), nil } diff --git a/pkg/scd/store/memstore/operational_intents_test.go b/pkg/scd/store/memstore/operational_intents_test.go new file mode 100644 index 000000000..1d70079dd --- /dev/null +++ b/pkg/scd/store/memstore/operational_intents_test.go @@ -0,0 +1,214 @@ +package memstore + +import ( + "testing" + "time" + + dssmodels "github.com/interuss/dss/pkg/models" + scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/stretchr/testify/require" +) + +func TestOperationalIntentUpsertGetDelete(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + got, err := r.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + require.Equal(t, operationalIntentId, got.ID) + require.Equal(t, scdmodels.OperationalIntentStateAccepted, got.State) + require.NotEmpty(t, got.OVN) + // No availability stored yet: defaults to Unknown. + require.Equal(t, scdmodels.UssAvailabilityStateUnknown, got.UssAvailability) + + count, err := r.CountOperationalIntents(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + require.NoError(t, r.DeleteOperationalIntent(ctx, operationalIntentId)) + gone, err := r.GetOperationalIntent(ctx, operationalIntentId) + require.NoError(t, err) + require.Nil(t, gone) +} + +func TestOperationalIntentGetMissingReturnsNil(t *testing.T) { + r := setUpStore(t) + got, err := r.GetOperationalIntent(writeCtx(), operationalIntentId) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestOperationalIntentDeleteMissingErrors(t *testing.T) { + r := setUpStore(t) + require.Error(t, r.DeleteOperationalIntent(writeCtx(), operationalIntentId)) +} + +func TestOperationalIntentUssAvailabilityAttached(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + _, err := r.UpsertUssAvailability(ctx, sampleAvailability()) + require.NoError(t, err) + _, err = r.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + + got, err := r.GetOperationalIntent(ctx, operationalIntentId) + require.NoError(t, err) + require.Equal(t, scdmodels.UssAvailabilityStateNormal, got.UssAvailability) +} + +func TestSearchOperationalIntents(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + _, err := r.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + + res, err := r.SearchOperationalIntents(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, res, 1) + + // Altitude window entirely above the operational intent excludes it. + var lo float32 = 200 + res, err = r.SearchOperationalIntents(ctx, volume4D(cells, nil, nil, &lo, nil)) + require.NoError(t, err) + require.Empty(t, res) + + // Altitude window entirely below the operational intent excludes it. + var hi float32 = 10 + res, err = r.SearchOperationalIntents(ctx, volume4D(cells, nil, nil, nil, &hi)) + require.NoError(t, err) + require.Empty(t, res) + + // Missing footprint is a bad request. + _, err = r.SearchOperationalIntents(ctx, &dssmodels.Volume4D{}) + require.Error(t, err) +} + +func TestGetDependentOperationalIntents(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + _, err := r.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + + deps, err := r.GetDependentOperationalIntents(ctx, subscriptionId) + require.NoError(t, err) + require.Equal(t, []dssmodels.ID{operationalIntentId}, deps) + + deps, err = r.GetDependentOperationalIntents(ctx, "other") + require.NoError(t, err) + require.Nil(t, deps) +} + +var ( + oi1ID = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca30000") + oi2ID = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca30001") + oi3ID = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca30003") + + start1 = time.Date(2024, time.August, 14, 15, 48, 36, 0, time.UTC) + end1 = start1.Add(time.Hour) + start2 = time.Date(2024, time.September, 15, 15, 48, 36, 0, time.UTC) + end2 = start2.Add(time.Hour) + start3 = time.Date(2024, time.September, 16, 15, 48, 36, 0, time.UTC) + end3 = start3.Add(time.Hour) +) + +var ( + oi1 = &scdmodels.OperationalIntent{ + ID: oi1ID, + Manager: "unittest", + Version: 1, + State: scdmodels.OperationalIntentStateAccepted, + StartTime: &start1, + EndTime: &end1, + USSBaseURL: "https://dummy.uss", + SubscriptionID: &sub1ID, + AltitudeLower: &altLow, + AltitudeUpper: &altHigh, + Cells: cells, + } + oi2 = &scdmodels.OperationalIntent{ + ID: oi2ID, + Manager: "unittest", + Version: 1, + State: scdmodels.OperationalIntentStateAccepted, + StartTime: &start2, + EndTime: &end2, + USSBaseURL: "https://dummy.uss", + SubscriptionID: &sub2ID, + AltitudeLower: &altLow, + AltitudeUpper: &altHigh, + Cells: cells, + } + oi3 = &scdmodels.OperationalIntent{ + ID: oi3ID, + Manager: "unittest", + Version: 1, + State: scdmodels.OperationalIntentStateAccepted, + StartTime: &start3, + EndTime: &end3, + USSBaseURL: "https://dummy.uss", + SubscriptionID: &sub3ID, + AltitudeLower: &altLow, + AltitudeUpper: &altHigh, + Cells: cells, + } +) + +func TestListExpiredOperationalIntents(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + _, err := r.UpsertSubscription(ctx, sub1) + require.NoError(t, err) + _, err = r.UpsertOperationalIntent(ctx, oi1) + require.NoError(t, err) + + _, err = r.UpsertSubscription(ctx, sub2) + require.NoError(t, err) + _, err = r.UpsertOperationalIntent(ctx, oi2) + require.NoError(t, err) + + _, err = r.UpsertSubscription(ctx, sub3) + require.NoError(t, err) + _, err = r.UpsertOperationalIntent(ctx, oi3) + require.NoError(t, err) + + testCases := []struct { + name string + timeRef time.Time + ttl time.Duration + expired []dssmodels.ID + }{{ + name: "none expired, one in close past", + timeRef: time.Date(2024, time.August, 25, 15, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{}, + }, { + name: "one recently expired, one current, one in future", + timeRef: time.Date(2024, time.September, 15, 16, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{oi1ID}, + }, { + name: "two expired, one in future", + timeRef: time.Date(2024, time.September, 16, 16, 0, 0, 0, time.UTC), + ttl: time.Hour * 2, + expired: []dssmodels.ID{oi1ID, oi2ID}, + }, { + name: "all expired", + timeRef: time.Date(2024, time.December, 15, 15, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{oi1ID, oi2ID, oi3ID}, + }} + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + threshold := testCase.timeRef.Add(-testCase.ttl) + expired, err := r.ListExpiredOperationalIntents(ctx, threshold) + require.NoError(t, err) + + expiredIDs := make([]dssmodels.ID, 0, len(expired)) + for _, expiredOi := range expired { + expiredIDs = append(expiredIDs, expiredOi.ID) + } + require.ElementsMatch(t, expiredIDs, testCase.expired) + }) + } +} diff --git a/pkg/scd/store/memstore/snapshot.go b/pkg/scd/store/memstore/snapshot.go index dea64e6a4..6688121f8 100644 --- a/pkg/scd/store/memstore/snapshot.go +++ b/pkg/scd/store/memstore/snapshot.go @@ -1,13 +1,49 @@ package memstore import ( + "bytes" + "encoding/gob" + + dssmodels "github.com/interuss/dss/pkg/models" "github.com/interuss/stacktrace" ) +const snapshotVersion = 1 + +type snapshotEnvelope struct { + Version int + State state +} + func (r *repo) GetSnapshot() ([]byte, error) { - return nil, stacktrace.NewError("GetSnapshot not yet implemented for rid") + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(snapshotEnvelope{Version: snapshotVersion, State: r.state}); err != nil { + return nil, stacktrace.Propagate(err, "Failed to encode memstore snapshot") + } + return buf.Bytes(), nil } func (r *repo) RestoreFromSnapshot(data []byte) error { - return stacktrace.NewError("RestoreFromSnapshot not yet implemented for rid") + var env snapshotEnvelope + if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&env); err != nil { + return stacktrace.Propagate(err, "Failed to decode memstore snapshot") + } + if env.Version != snapshotVersion { + return stacktrace.NewError("Unsupported memstore snapshot version %d, expected %d", env.Version, snapshotVersion) + } + r.state = env.State + // gob decodes an empty map as nil; re-initialize to keep the repo writable. + if r.state.Constraints == nil { + r.state.Constraints = map[dssmodels.ID]*constraintRecord{} + } + if r.state.Subscriptions == nil { + r.state.Subscriptions = map[dssmodels.ID]*subscriptionRecord{} + } + if r.state.OperationalIntents == nil { + r.state.OperationalIntents = map[dssmodels.ID]*operationalIntentRecord{} + } + if r.state.Availabilities == nil { + r.state.Availabilities = map[dssmodels.Manager]*availabilityRecord{} + } + return nil } diff --git a/pkg/scd/store/memstore/snapshot_test.go b/pkg/scd/store/memstore/snapshot_test.go new file mode 100644 index 000000000..5f7d24380 --- /dev/null +++ b/pkg/scd/store/memstore/snapshot_test.go @@ -0,0 +1,99 @@ +package memstore + +import ( + "bytes" + "encoding/gob" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" +) + +func TestSnapshotRoundTrip(t *testing.T) { + ctx := writeCtx() + src := setUpStore(t) + _, err := src.UpsertConstraint(ctx, sampleConstraint()) + require.NoError(t, err) + _, err = src.UpsertSubscription(ctx, sampleSubscription()) + require.NoError(t, err) + _, err = src.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + _, err = src.UpsertUssAvailability(ctx, sampleAvailability()) + require.NoError(t, err) + + data, err := src.GetSnapshot() + require.NoError(t, err) + + dst := setUpStore(t) + require.NoError(t, dst.RestoreFromSnapshot(data)) + + opt := cmpopts.EquateApproxTime(0) + + wantCon, err := src.GetConstraint(ctx, constraintId) + require.NoError(t, err) + gotCon, err := dst.GetConstraint(ctx, constraintId) + require.NoError(t, err) + if diff := cmp.Diff(wantCon, gotCon, opt); diff != "" { + t.Errorf("Constraint mismatch (-want +got):\n%s", diff) + } + + wantSub, err := src.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + gotSub, err := dst.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + if diff := cmp.Diff(wantSub, gotSub, opt); diff != "" { + t.Errorf("Subscription mismatch (-want +got):\n%s", diff) + } + + wantOI, err := src.GetOperationalIntent(ctx, operationalIntentId) + require.NoError(t, err) + gotOI, err := dst.GetOperationalIntent(ctx, operationalIntentId) + require.NoError(t, err) + if diff := cmp.Diff(wantOI, gotOI, opt); diff != "" { + t.Errorf("OperationalIntent mismatch (-want +got):\n%s", diff) + } + + wantAvail, err := src.GetUssAvailability(ctx, manager) + require.NoError(t, err) + gotAvail, err := dst.GetUssAvailability(ctx, manager) + require.NoError(t, err) + if diff := cmp.Diff(wantAvail, gotAvail, opt); diff != "" { + t.Errorf("UssAvailability mismatch (-want +got):\n%s", diff) + } +} + +func TestRestoreFromSnapshotReplacesState(t *testing.T) { + ctx := writeCtx() + src := setUpStore(t) + _, err := src.UpsertConstraint(ctx, sampleConstraint()) + require.NoError(t, err) + data, err := src.GetSnapshot() + require.NoError(t, err) + + dst := setUpStore(t) + other := sampleConstraint() + other.ID = "00000185-e36d-40be-8d38-beca6ca39999" + _, err = dst.UpsertConstraint(ctx, other) + require.NoError(t, err) + require.NoError(t, dst.RestoreFromSnapshot(data)) + + count, err := dst.CountConstraints(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), count) + got, err := dst.GetConstraint(ctx, constraintId) + require.NoError(t, err) + require.NotNil(t, got) + _, err = dst.GetConstraint(ctx, other.ID) + require.Error(t, err) +} + +func TestRestoreFromSnapshotInvalidData(t *testing.T) { + require.Error(t, setUpStore(t).RestoreFromSnapshot([]byte("random value that is definitely not valid"))) +} + +func TestRestoreFromSnapshotVersionMismatch(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, gob.NewEncoder(&buf).Encode(snapshotEnvelope{Version: snapshotVersion + 1})) + require.Error(t, setUpStore(t).RestoreFromSnapshot(buf.Bytes())) +} diff --git a/pkg/scd/store/memstore/store.go b/pkg/scd/store/memstore/store.go index 06d187ee3..d6bb7b100 100644 --- a/pkg/scd/store/memstore/store.go +++ b/pkg/scd/store/memstore/store.go @@ -2,25 +2,264 @@ package memstore import ( "context" + "slices" + "time" + "github.com/golang/geo/s2" "github.com/interuss/dss/pkg/memstore" + "github.com/interuss/dss/pkg/memstore/utils" + dssmodels "github.com/interuss/dss/pkg/models" + scdmodels "github.com/interuss/dss/pkg/scd/models" "github.com/interuss/dss/pkg/scd/repos" + "github.com/interuss/stacktrace" "go.uber.org/zap" ) // repo is a full implementation of scd.repos.Repository for memory-based storage. -type repo struct{} +type repo struct { + state state + checkpoint state +} + +// state is the serializable in-memory state. +type state struct { + // Constraints holds the stored constraints keyed by ID. + Constraints map[dssmodels.ID]*constraintRecord + // Subscriptions holds the stored subscriptions keyed by ID. + Subscriptions map[dssmodels.ID]*subscriptionRecord + // OperationalIntents holds the stored operational intents keyed by ID. + OperationalIntents map[dssmodels.ID]*operationalIntentRecord + // Availabilities holds the stored USS availabilities keyed by USS Manager. + Availabilities map[dssmodels.Manager]*availabilityRecord +} + +// constraintRecord is the gob-serializable representation of a Constraint. The +// model's OVN is never persisted: it is derived from UpdatedAt on read +type constraintRecord struct { + ID dssmodels.ID + Manager dssmodels.Manager + Version scdmodels.VersionNumber + StartTime *time.Time + EndTime *time.Time + USSBaseURL string + AltitudeLower *float32 + AltitudeUpper *float32 + Cells s2.CellUnion + UpdatedAt time.Time +} + +// subscriptionRecord is the gob-serializable representation of a Subscription. +// The sqlstore stores the version column but always writes 0 and discards it on +// read (the model Version is derived from UpdatedAt), so it is not kept here. +type subscriptionRecord struct { + ID dssmodels.ID + Manager dssmodels.Manager + NotificationIndex int + USSBaseURL string + NotifyForOperationalIntents bool + NotifyForConstraints bool + ImplicitSubscription bool + StartTime *time.Time + EndTime *time.Time + Cells s2.CellUnion + UpdatedAt time.Time +} + +// operationalIntentRecord is the gob-serializable representation of an +// OperationalIntent. USSRequestedOVN is empty when the OVN is DSS-generated. +type operationalIntentRecord struct { + ID dssmodels.ID + Manager dssmodels.Manager + Version scdmodels.VersionNumber + State scdmodels.OperationalIntentState + StartTime *time.Time + EndTime *time.Time + USSBaseURL string + SubscriptionID *dssmodels.ID + AltitudeLower *float32 + AltitudeUpper *float32 + Cells s2.CellUnion + USSRequestedOVN string + PastOVNs []scdmodels.OVN + UpdatedAt time.Time +} + +// availabilityRecord is the gob-serializable representation of a +// UssAvailabilityStatus. The model's Version is derived from UpdatedAt on read. +type availabilityRecord struct { + Uss dssmodels.Manager + Availability scdmodels.UssAvailabilityState + UpdatedAt time.Time +} + +func newRepo() *repo { + r := &repo{} + r.resetState() + return r +} + +func (r *repo) resetState() { + r.state = state{ + Constraints: map[dssmodels.ID]*constraintRecord{}, + Subscriptions: map[dssmodels.ID]*subscriptionRecord{}, + OperationalIntents: map[dssmodels.ID]*operationalIntentRecord{}, + Availabilities: map[dssmodels.Manager]*availabilityRecord{}, + } + r.Checkpoint() +} func Init(ctx context.Context, logger *zap.Logger) (*memstore.Store[repos.Repository], error) { - return memstore.Init(ctx, logger, "scd", &repo{}) + return memstore.Init(ctx, logger, "scd", newRepo()) } func (r *repo) GetRepo() repos.Repository { return r } +// cellSet builds a lookup set from a cell union. +func cellSet(cells s2.CellUnion) map[s2.CellID]struct{} { + set := make(map[s2.CellID]struct{}, len(cells)) + for _, c := range cells { + set[c] = struct{}{} + } + return set +} + +// coveringSet builds a lookup set from the spatial covering of a volume. +func coveringSet(v4d *dssmodels.Volume4D) (map[s2.CellID]struct{}, error) { + cells, err := v4d.CalculateSpatialCovering() + if err != nil { + return nil, stacktrace.Propagate(err, "Could not calculate spatial covering") + } + return cellSet(cells), nil +} + +// overlaps reports whether any cell is present in set (equivalent to the SQL +// "cells && $x" array-overlap operator). +func overlaps(cells s2.CellUnion, set map[s2.CellID]struct{}) bool { + for _, c := range cells { + if _, ok := set[c]; ok { + return true + } + } + return false +} + +// overlapsTime reports whether the [start, end] interval of a record intersects the one of v4d +// (equivalent to the SQL "COALESCE(starts_at <= $end, true) AND COALESCE(ends_at >= $start, true)"). +func overlapsTime(start, end *time.Time, v4d *dssmodels.Volume4D) bool { + if start != nil && v4d.EndTime != nil && start.After(*v4d.EndTime) { // TODO: Don't allow startup to be null, see #1492 + return false + } + if end != nil && v4d.StartTime != nil && end.Before(*v4d.StartTime) { // TODO: Don't allow endtime to be null, see #1492 + return false + } + return true +} + +type expiringRecord interface { + endTime() *time.Time + lastUpdate() time.Time +} + +func (rec *subscriptionRecord) endTime() *time.Time { return rec.EndTime } + +func (rec *subscriptionRecord) lastUpdate() time.Time { return rec.UpdatedAt } + +func (rec *operationalIntentRecord) endTime() *time.Time { return rec.EndTime } + +func (rec *operationalIntentRecord) lastUpdate() time.Time { return rec.UpdatedAt } + +// listExpired returns the records whose end time is at or before threshold, falling back on the +// last update time when the end time is unknown. A limit of 0 means unlimited. +func listExpired[R expiringRecord](store map[dssmodels.ID]R, threshold time.Time, limit int) []R { + var out []R + for _, rec := range store { + // (ends_at IS NOT NULL AND ends_at <= threshold) OR (ends_at IS NULL AND updated_at <= threshold) + if t := rec.endTime(); t != nil { // TODO: Don't allow endtime to be null, see #1492 + if t.After(threshold) { + continue + } + } else if rec.lastUpdate().After(threshold) { + continue + } + out = append(out, rec) + + if limit > 0 && len(out) >= limit { // mirror SQL "LIMIT MaxResultLimit" + break + } + } + return out +} + +func (rec *constraintRecord) clone() *constraintRecord { + cp := *rec + cp.Cells = slices.Clone(rec.Cells) + cp.StartTime = utils.ClonePtr(rec.StartTime) + cp.EndTime = utils.ClonePtr(rec.EndTime) + cp.AltitudeLower = utils.ClonePtr(rec.AltitudeLower) + cp.AltitudeUpper = utils.ClonePtr(rec.AltitudeUpper) + return &cp +} + +func (rec *subscriptionRecord) clone() *subscriptionRecord { + cp := *rec + cp.Cells = slices.Clone(rec.Cells) + cp.StartTime = utils.ClonePtr(rec.StartTime) + cp.EndTime = utils.ClonePtr(rec.EndTime) + return &cp +} + +func (rec *operationalIntentRecord) clone() *operationalIntentRecord { + cp := *rec + cp.Cells = slices.Clone(rec.Cells) + cp.PastOVNs = slices.Clone(rec.PastOVNs) + cp.StartTime = utils.ClonePtr(rec.StartTime) + cp.EndTime = utils.ClonePtr(rec.EndTime) + cp.SubscriptionID = utils.ClonePtr(rec.SubscriptionID) + cp.AltitudeLower = utils.ClonePtr(rec.AltitudeLower) + cp.AltitudeUpper = utils.ClonePtr(rec.AltitudeUpper) + return &cp +} + +func (rec *availabilityRecord) clone() *availabilityRecord { + cp := *rec + return &cp +} + +// clone returns a deep copy of s. May be optimzed in speed by not cloning everything, as long +// rest of the package don't mutate fields, iff speed of this function is important. +func (s state) clone() state { + constraints := make(map[dssmodels.ID]*constraintRecord, len(s.Constraints)) + for id, rec := range s.Constraints { + constraints[id] = rec.clone() + } + subs := make(map[dssmodels.ID]*subscriptionRecord, len(s.Subscriptions)) + for id, rec := range s.Subscriptions { + subs[id] = rec.clone() + } + ois := make(map[dssmodels.ID]*operationalIntentRecord, len(s.OperationalIntents)) + for id, rec := range s.OperationalIntents { + ois[id] = rec.clone() + } + avails := make(map[dssmodels.Manager]*availabilityRecord, len(s.Availabilities)) + for uss, rec := range s.Availabilities { + avails[uss] = rec.clone() + } + return state{ + Constraints: constraints, + Subscriptions: subs, + OperationalIntents: ois, + Availabilities: avails, + } +} + +// Checkpoint ask the repo to store a quick, internal checkpoint with its current state. +// There is at most one check point, any existing checkpoint is overwritten func (r *repo) Checkpoint() { - panic("Checkpoint not yet implemented for scd") + r.checkpoint = r.state.clone() } +// Restore replaces the current state with the latest checkpoint. May be called multiple time +// to restore the same checkpoint. func (r *repo) Restore() { - panic("Restore not yet implemented for scd") + r.state = r.checkpoint.clone() } diff --git a/pkg/scd/store/memstore/store_test.go b/pkg/scd/store/memstore/store_test.go new file mode 100644 index 000000000..7f43bdf8c --- /dev/null +++ b/pkg/scd/store/memstore/store_test.go @@ -0,0 +1,166 @@ +package memstore + +import ( + "context" + "testing" + "time" + + "github.com/golang/geo/s2" + dssmodels "github.com/interuss/dss/pkg/models" + scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/timestamp" + "github.com/stretchr/testify/require" +) + +var ( + manager = dssmodels.Manager("unittest") + + constraintId = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca31000") + subscriptionId = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca31001") + operationalIntentId = dssmodels.ID("00000185-e36d-40be-8d38-beca6ca31002") + + cells = s2.CellUnion{ + s2.CellID(int64(8768904281496485888)), + s2.CellID(int64(8768904178417270784)), + } + + startTime = time.Date(2024, time.August, 14, 15, 48, 36, 0, time.UTC) + endTime = startTime.Add(time.Hour) + writeTime = time.Date(2024, time.August, 1, 0, 0, 0, 0, time.UTC) + + altLow, altHigh float32 = 84, 169 +) + +// setUpStore returns a fresh in-memory repo. +func setUpStore(t *testing.T) *repo { + t.Helper() + return newRepo() +} + +// 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) +} + +func sampleConstraint() *scdmodels.Constraint { + return &scdmodels.Constraint{ + ID: constraintId, + Manager: manager, + Version: 1, + StartTime: &startTime, + EndTime: &endTime, + USSBaseURL: "https://dummy.uss", + AltitudeLower: &altLow, + AltitudeUpper: &altHigh, + Cells: cells, + } +} + +func sampleSubscription() *scdmodels.Subscription { + return &scdmodels.Subscription{ + ID: subscriptionId, + Manager: manager, + NotificationIndex: 1, + USSBaseURL: "https://dummy.uss", + NotifyForOperationalIntents: true, + NotifyForConstraints: true, + StartTime: &startTime, + EndTime: &endTime, + Cells: cells, + } +} + +func sampleOperationalIntent() *scdmodels.OperationalIntent { + sid := subscriptionId + return &scdmodels.OperationalIntent{ + ID: operationalIntentId, + Manager: manager, + Version: 1, + State: scdmodels.OperationalIntentStateAccepted, + StartTime: &startTime, + EndTime: &endTime, + USSBaseURL: "https://dummy.uss", + SubscriptionID: &sid, + AltitudeLower: &altLow, + AltitudeUpper: &altHigh, + Cells: cells, + } +} + +func sampleAvailability() *scdmodels.UssAvailabilityStatus { + return &scdmodels.UssAvailabilityStatus{ + Uss: manager, + Availability: scdmodels.UssAvailabilityStateNormal, + } +} + +// volume4D builds a Volume4D whose footprint covers the provided cells. +func volume4D(cu s2.CellUnion, start, end *time.Time, altLo, altHi *float32) *dssmodels.Volume4D { + return &dssmodels.Volume4D{ + StartTime: start, + EndTime: end, + SpatialVolume: &dssmodels.Volume3D{ + AltitudeLo: altLo, + AltitudeHi: altHi, + Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { + return cu, nil + }), + }, + } +} + +func TestCheckpointRestoreRoundTrip(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + _, err := r.UpsertConstraint(ctx, sampleConstraint()) + require.NoError(t, err) + _, err = r.UpsertSubscription(ctx, sampleSubscription()) + require.NoError(t, err) + _, err = r.UpsertOperationalIntent(ctx, sampleOperationalIntent()) + require.NoError(t, err) + _, err = r.UpsertUssAvailability(ctx, sampleAvailability()) + require.NoError(t, err) + + r.Checkpoint() + + // Mutate after the checkpoint. + require.NoError(t, r.DeleteConstraint(ctx, constraintId)) + require.NoError(t, r.DeleteSubscription(ctx, subscriptionId)) + require.NoError(t, r.DeleteOperationalIntent(ctx, operationalIntentId)) + + // Restore brings everything back. + r.Restore() + + con, err := r.GetConstraint(ctx, constraintId) + require.NoError(t, err) + require.NotNil(t, con) + sub, err := r.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + require.NotNil(t, sub) + oi, err := r.GetOperationalIntent(ctx, operationalIntentId) + require.NoError(t, err) + require.NotNil(t, oi) +} + +func TestCheckpointIsolatesNotificationIndex(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + sub, err := r.UpsertSubscription(ctx, sampleSubscription()) + require.NoError(t, err) + + r.Checkpoint() + + // In-place notification-index bump must not leak into the checkpoint. + bumped, err := r.IncrementNotificationIndicesForOperationalIntents(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, bumped, 1) + require.Equal(t, sub.NotificationIndex+1, bumped[0].NotificationIndex) + + r.Restore() + restored, err := r.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + require.Equal(t, sub.NotificationIndex, restored.NotificationIndex) +} diff --git a/pkg/scd/store/memstore/subscriptions.go b/pkg/scd/store/memstore/subscriptions.go index c284e6df8..02f233698 100644 --- a/pkg/scd/store/memstore/subscriptions.go +++ b/pkg/scd/store/memstore/subscriptions.go @@ -2,47 +2,149 @@ package memstore import ( "context" + "iter" + "slices" "time" "github.com/golang/geo/s2" - dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/memstore/utils" dssmodels "github.com/interuss/dss/pkg/models" scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/interuss/dss/pkg/timestamp" "github.com/interuss/stacktrace" ) +func (rec *subscriptionRecord) toModel() *scdmodels.Subscription { + return &scdmodels.Subscription{ + ID: rec.ID, + Version: scdmodels.NewOVNFromTime(rec.UpdatedAt, rec.ID.String()), + NotificationIndex: rec.NotificationIndex, + Manager: rec.Manager, + StartTime: utils.ClonePtr(rec.StartTime), + EndTime: utils.ClonePtr(rec.EndTime), + USSBaseURL: rec.USSBaseURL, + NotifyForOperationalIntents: rec.NotifyForOperationalIntents, + NotifyForConstraints: rec.NotifyForConstraints, + ImplicitSubscription: rec.ImplicitSubscription, + Cells: slices.Clone(rec.Cells), + } +} + +// subscriptionsInVolume4D yields the subscriptions intersecting v4d. +func (r *repo) subscriptionsInVolume4D(v4d *dssmodels.Volume4D) (iter.Seq[*subscriptionRecord], error) { + want, err := coveringSet(v4d) + if err != nil { + return nil, err + } + + return func(yield func(*subscriptionRecord) bool) { + for _, rec := range r.state.Subscriptions { + if !overlaps(rec.Cells, want) { + continue + } + if !overlapsTime(rec.StartTime, rec.EndTime, v4d) { + continue + } + if !yield(rec) { + return + } + } + }, nil +} + func (r *repo) SearchSubscriptions(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "SearchSubscriptions not implemented for memstore") + subscriptions, err := r.subscriptionsInVolume4D(v4d) + if err != nil { + return nil, err + } + + var out []*scdmodels.Subscription + for rec := range subscriptions { + out = append(out, rec.toModel()) + + if len(out) >= dssmodels.MaxResultLimit { // mirror SQL "LIMIT MaxResultLimit" + break + } + } + return out, nil } func (r *repo) GetSubscription(_ context.Context, id dssmodels.ID) (*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "GetSubscription not implemented for memstore") + rec, ok := r.state.Subscriptions[id] + if !ok { + return nil, nil + } + return rec.toModel(), nil } -func (r *repo) UpsertSubscription(_ context.Context, sub *scdmodels.Subscription) (*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "UpsertSubscription not implemented for memstore") +func (r *repo) UpsertSubscription(ctx context.Context, s *scdmodels.Subscription) (*scdmodels.Subscription, error) { + now := timestamp.MustGetRequestTimestamp(ctx) + + rec := &subscriptionRecord{ + ID: s.ID, + Manager: s.Manager, + NotificationIndex: s.NotificationIndex, + USSBaseURL: s.USSBaseURL, + NotifyForOperationalIntents: s.NotifyForOperationalIntents, + NotifyForConstraints: s.NotifyForConstraints, + ImplicitSubscription: s.ImplicitSubscription, + StartTime: utils.ClonePtr(s.StartTime), + EndTime: utils.ClonePtr(s.EndTime), + Cells: slices.Clone(s.Cells), + UpdatedAt: now, + } + r.state.Subscriptions[s.ID] = rec + return rec.toModel(), nil } func (r *repo) DeleteSubscription(_ context.Context, id dssmodels.ID) error { - return stacktrace.NewErrorWithCode(dsserr.NotImplemented, "DeleteSubscription not implemented for memstore") + if _, ok := r.state.Subscriptions[id]; !ok { + return stacktrace.NewError("Attempted to delete non-existent Subscription") + } + delete(r.state.Subscriptions, id) + return nil } func (r *repo) IncrementNotificationIndicesForOperationalIntents(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "IncrementNotificationIndicesForOperationalIntents not implemented for memstore") + return r.incrementNotificationIndices(v4d, func(rec *subscriptionRecord) bool { return rec.NotifyForOperationalIntents }) } func (r *repo) IncrementNotificationIndicesForConstraints(_ context.Context, v4d *dssmodels.Volume4D) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "IncrementNotificationIndicesForConstraints not implemented for memstore") + return r.incrementNotificationIndices(v4d, func(rec *subscriptionRecord) bool { return rec.NotifyForConstraints }) +} + +// incrementNotificationIndices increments the notification index of each subscription +// intersecting v4d and asking for the notifications selected by notified. +func (r *repo) incrementNotificationIndices(v4d *dssmodels.Volume4D, notified func(*subscriptionRecord) bool) ([]*scdmodels.Subscription, error) { + subscriptions, err := r.subscriptionsInVolume4D(v4d) + if err != nil { + return nil, err + } + + var out []*scdmodels.Subscription + for rec := range subscriptions { + if !notified(rec) { + continue + } + rec.NotificationIndex++ + out = append(out, rec.toModel()) + } + return out, 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 memstore") +func (r *repo) LockSubscriptionsOnCells(_ context.Context, _ s2.CellUnion, _ []dssmodels.ID, _ *time.Time, _ *time.Time) error { + // For the memory store, that a no-op + return nil } func (r *repo) ListExpiredSubscriptions(_ context.Context, threshold time.Time) ([]*scdmodels.Subscription, error) { - return nil, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "ListExpiredSubscriptions not implemented for memstore") + var out []*scdmodels.Subscription + for _, rec := range listExpired(r.state.Subscriptions, threshold, dssmodels.MaxResultLimit) { + out = append(out, rec.toModel()) + } + return out, nil } func (r *repo) CountSubscriptions(_ context.Context) (int64, error) { - return 0, stacktrace.NewErrorWithCode(dsserr.NotImplemented, "CountSubscriptions not implemented for memstore") + return int64(len(r.state.Subscriptions)), nil } diff --git a/pkg/scd/store/memstore/subscriptions_test.go b/pkg/scd/store/memstore/subscriptions_test.go new file mode 100644 index 000000000..2176e411b --- /dev/null +++ b/pkg/scd/store/memstore/subscriptions_test.go @@ -0,0 +1,234 @@ +package memstore + +import ( + "testing" + "time" + + "github.com/golang/geo/s2" + dssmodels "github.com/interuss/dss/pkg/models" + scdmodels "github.com/interuss/dss/pkg/scd/models" + "github.com/stretchr/testify/require" +) + +func TestSubscriptionUpsertGetDelete(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + got, err := r.UpsertSubscription(ctx, sampleSubscription()) + require.NoError(t, err) + require.Equal(t, subscriptionId, got.ID) + require.Equal(t, 1, got.NotificationIndex) + require.NotEmpty(t, got.Version) + + fetched, err := r.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + require.Equal(t, got.Version, fetched.Version) + require.True(t, fetched.NotifyForOperationalIntents) + + count, err := r.CountSubscriptions(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + require.NoError(t, r.DeleteSubscription(ctx, subscriptionId)) + gone, err := r.GetSubscription(ctx, subscriptionId) + require.NoError(t, err) + require.Nil(t, gone) +} + +func TestSubscriptionGetMissingReturnsNil(t *testing.T) { + r := setUpStore(t) + got, err := r.GetSubscription(writeCtx(), subscriptionId) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestSubscriptionDeleteMissingErrors(t *testing.T) { + r := setUpStore(t) + require.Error(t, r.DeleteSubscription(writeCtx(), subscriptionId)) +} + +func TestSearchSubscriptions(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + _, err := r.UpsertSubscription(ctx, sampleSubscription()) + require.NoError(t, err) + + res, err := r.SearchSubscriptions(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, res, 1) + + // No covering cells returns nil. + res, err = r.SearchSubscriptions(ctx, volume4D(s2.CellUnion{}, nil, nil, nil, nil)) + require.NoError(t, err) + require.Nil(t, res) +} + +func TestIncrementNotificationIndicesForOperationalIntents(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + // notify_for_operations = true. + opSub := sampleSubscription() + _, err := r.UpsertSubscription(ctx, opSub) + require.NoError(t, err) + + // A second subscription that only wants constraint notifications must be skipped. + conSub := sampleSubscription() + conSub.ID = "00000185-e36d-40be-8d38-beca6ca31aaa" + conSub.NotifyForOperationalIntents = false + conSub.NotifyForConstraints = true + _, err = r.UpsertSubscription(ctx, conSub) + require.NoError(t, err) + + got, err := r.IncrementNotificationIndicesForOperationalIntents(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, opSub.ID, got[0].ID) + require.Equal(t, opSub.NotificationIndex+1, got[0].NotificationIndex) + + // The bump is persisted. + fetched, err := r.GetSubscription(ctx, opSub.ID) + require.NoError(t, err) + require.Equal(t, opSub.NotificationIndex+1, fetched.NotificationIndex) + + // The constraint-only subscription was untouched. + other, err := r.GetSubscription(ctx, conSub.ID) + require.NoError(t, err) + require.Equal(t, conSub.NotificationIndex, other.NotificationIndex) + + // No covering cells returns nil. + got, err = r.IncrementNotificationIndicesForOperationalIntents(ctx, volume4D(s2.CellUnion{}, nil, nil, nil, nil)) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestIncrementNotificationIndicesForConstraints(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + // notify_for_constraints = true (sample sets both notify flags). + conSub := sampleSubscription() + _, err := r.UpsertSubscription(ctx, conSub) + require.NoError(t, err) + + // A subscription that does not want constraint notifications must be skipped. + opSub := sampleSubscription() + opSub.ID = "00000185-e36d-40be-8d38-beca6ca31bbb" + opSub.NotifyForConstraints = false + _, err = r.UpsertSubscription(ctx, opSub) + require.NoError(t, err) + + got, err := r.IncrementNotificationIndicesForConstraints(ctx, volume4D(cells, nil, nil, nil, nil)) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, conSub.ID, got[0].ID) + require.Equal(t, conSub.NotificationIndex+1, got[0].NotificationIndex) + + other, err := r.GetSubscription(ctx, opSub.ID) + require.NoError(t, err) + require.Equal(t, opSub.NotificationIndex, other.NotificationIndex) +} + +func TestLockSubscriptionsOnCellsNoop(t *testing.T) { + r := setUpStore(t) + require.NoError(t, r.LockSubscriptionsOnCells(writeCtx(), cells, []dssmodels.ID{subscriptionId}, nil, nil)) +} + +var ( + sub1ID = dssmodels.ID("189ec22f-5e61-418a-940b-36de2d201fd5") + sub2ID = dssmodels.ID("78f98cc5-94f3-4c04-8da9-a8398feba3f3") + sub3ID = dssmodels.ID("9f0d4575-b275-4a4c-a261-e1e04d324565") +) + +var ( + sub1 = &scdmodels.Subscription{ + ID: sub1ID, + NotificationIndex: 1, + Manager: "unittest", + StartTime: &start1, + EndTime: &end1, + USSBaseURL: "https://dummy.uss", + NotifyForOperationalIntents: true, + NotifyForConstraints: false, + ImplicitSubscription: true, + Cells: cells, + } + sub2 = &scdmodels.Subscription{ + ID: sub2ID, + NotificationIndex: 1, + Manager: "unittest", + StartTime: &start2, + EndTime: &end2, + USSBaseURL: "https://dummy.uss", + NotifyForOperationalIntents: true, + NotifyForConstraints: false, + ImplicitSubscription: true, + Cells: cells, + } + sub3 = &scdmodels.Subscription{ + ID: sub3ID, + NotificationIndex: 1, + Manager: "unittest", + StartTime: &start3, + EndTime: &end3, + USSBaseURL: "https://dummy.uss", + NotifyForOperationalIntents: true, + NotifyForConstraints: false, + ImplicitSubscription: true, + Cells: cells, + } +) + +func TestListExpiredSubscriptions(t *testing.T) { + ctx := writeCtx() + r := setUpStore(t) + + _, err := r.UpsertSubscription(ctx, sub1) + require.NoError(t, err) + + _, err = r.UpsertSubscription(ctx, sub2) + require.NoError(t, err) + + _, err = r.UpsertSubscription(ctx, sub3) + require.NoError(t, err) + + testCases := []struct { + name string + timeRef time.Time + ttl time.Duration + expired []dssmodels.ID + }{{ + name: "none expired, one in close past", + timeRef: time.Date(2024, time.August, 25, 15, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{}, + }, { + name: "one recently expired, one current, one in future", + timeRef: time.Date(2024, time.September, 15, 16, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{sub1ID}, + }, { + name: "two expired, one in future", + timeRef: time.Date(2024, time.September, 16, 16, 0, 0, 0, time.UTC), + ttl: time.Hour * 2, + expired: []dssmodels.ID{sub1ID, sub2ID}, + }, { + name: "all expired", + timeRef: time.Date(2024, time.December, 15, 15, 0, 0, 0, time.UTC), + ttl: time.Hour * 24 * 30, + expired: []dssmodels.ID{sub1ID, sub2ID, sub3ID}, + }} + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + threshold := testCase.timeRef.Add(-testCase.ttl) + expired, err := r.ListExpiredSubscriptions(ctx, threshold) + require.NoError(t, err) + + expiredIDs := make([]dssmodels.ID, 0, len(expired)) + for _, expiredSub := range expired { + expiredIDs = append(expiredIDs, expiredSub.ID) + } + require.ElementsMatch(t, expiredIDs, testCase.expired) + }) + } +}