Skip to content

Commit 268b914

Browse files
committed
Add dry-run support to WriteSchema API
Signed-off-by: Aditya Bhalerao <[email protected]>
1 parent f162cff commit 268b914

File tree

2 files changed

+322
-2
lines changed

2 files changed

+322
-2
lines changed

internal/services/shared/schema.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,127 @@ func ApplySchemaChangesOverExisting(
237237
}, nil
238238
}
239239

240+
// ApplySchemaChangesDryRun performs all the validation that ApplySchemaChanges would do,
241+
// but without actually writing any changes to the datastore.
242+
func ApplySchemaChangesDryRun(ctx context.Context, reader datastore.Reader, caveatTypeSet *caveattypes.TypeSet, validated *ValidatedSchemaChanges) (*AppliedSchemaChanges, error) {
243+
existingCaveats, err := reader.ListAllCaveats(ctx)
244+
if err != nil {
245+
return nil, err
246+
}
247+
248+
existingObjectDefs, err := reader.ListAllNamespaces(ctx)
249+
if err != nil {
250+
return nil, err
251+
}
252+
253+
return ApplySchemaChangesOverExistingDryRun(ctx, reader, caveatTypeSet, validated, datastore.DefinitionsOf(existingCaveats), datastore.DefinitionsOf(existingObjectDefs))
254+
}
255+
256+
// ApplySchemaChangesOverExistingDryRun performs dry-run validation of schema changes against
257+
// existing caveat and object definitions, without actually writing any changes.
258+
func ApplySchemaChangesOverExistingDryRun(
259+
ctx context.Context,
260+
reader datastore.Reader,
261+
caveatTypeSet *caveattypes.TypeSet,
262+
validated *ValidatedSchemaChanges,
263+
existingCaveats []*core.CaveatDefinition,
264+
existingObjectDefs []*core.NamespaceDefinition,
265+
) (*AppliedSchemaChanges, error) {
266+
// Build a map of existing caveats to determine those being removed, if any.
267+
existingCaveatDefMap := make(map[string]*core.CaveatDefinition, len(existingCaveats))
268+
existingCaveatDefNames := mapz.NewSet[string]()
269+
270+
for _, existingCaveat := range existingCaveats {
271+
existingCaveatDefMap[existingCaveat.Name] = existingCaveat
272+
existingCaveatDefNames.Insert(existingCaveat.Name)
273+
}
274+
275+
// For each caveat definition, perform a diff and ensure the changes will not result in type errors.
276+
caveatDefsWithChanges := make([]*core.CaveatDefinition, 0, len(validated.compiled.CaveatDefinitions))
277+
for _, caveatDef := range validated.compiled.CaveatDefinitions {
278+
diff, err := sanityCheckCaveatChanges(ctx, nil, caveatTypeSet, caveatDef, existingCaveatDefMap)
279+
if err != nil {
280+
return nil, err
281+
}
282+
283+
if len(diff.Deltas()) > 0 {
284+
caveatDefsWithChanges = append(caveatDefsWithChanges, caveatDef)
285+
}
286+
}
287+
288+
removedCaveatDefNames := existingCaveatDefNames.Subtract(validated.newCaveatDefNames)
289+
290+
// Build a map of existing definitions to determine those being removed, if any.
291+
existingObjectDefMap := make(map[string]*core.NamespaceDefinition, len(existingObjectDefs))
292+
existingObjectDefNames := mapz.NewSet[string]()
293+
for _, existingDef := range existingObjectDefs {
294+
existingObjectDefMap[existingDef.Name] = existingDef
295+
existingObjectDefNames.Insert(existingDef.Name)
296+
}
297+
298+
// For each definition, perform a diff and ensure the changes will not result in any
299+
// breaking changes.
300+
objectDefsWithChanges := make([]*core.NamespaceDefinition, 0, len(validated.compiled.ObjectDefinitions))
301+
for _, nsdef := range validated.compiled.ObjectDefinitions {
302+
diff, err := sanityCheckNamespaceChangesDryRun(ctx, reader, nsdef, existingObjectDefMap)
303+
if err != nil {
304+
return nil, err
305+
}
306+
307+
if len(diff.Deltas()) > 0 {
308+
objectDefsWithChanges = append(objectDefsWithChanges, nsdef)
309+
310+
vts, ok := validated.validatedTypeSystems[nsdef.Name]
311+
if !ok {
312+
return nil, spiceerrors.MustBugf("validated type system not found for namespace `%s`", nsdef.Name)
313+
}
314+
315+
if err := namespace.AnnotateNamespace(vts); err != nil {
316+
return nil, err
317+
}
318+
}
319+
}
320+
321+
log.Ctx(ctx).
322+
Trace().
323+
Int("objectDefinitions", len(validated.compiled.ObjectDefinitions)).
324+
Int("caveatDefinitions", len(validated.compiled.CaveatDefinitions)).
325+
Int("objectDefsWithChanges", len(objectDefsWithChanges)).
326+
Int("caveatDefsWithChanges", len(caveatDefsWithChanges)).
327+
Bool("dryRun", true).
328+
Msg("validated namespace definitions (dry run)")
329+
330+
// Ensure that deleting namespaces will not result in any relationships left without associated
331+
// schema. We only check the resource type as the subject type is handled by the schema validator,
332+
// which will allow the deletion of the subject type if it is not used in any relation anyway.
333+
removedObjectDefNames := existingObjectDefNames.Subtract(validated.newObjectDefNames)
334+
if !validated.additiveOnly {
335+
if err := removedObjectDefNames.ForEach(func(nsdefName string) error {
336+
return ensureNoRelationshipsExistWithResourceTypeDryRun(ctx, reader, nsdefName)
337+
}); err != nil {
338+
return nil, err
339+
}
340+
}
341+
342+
log.Ctx(ctx).Trace().
343+
Interface("objectDefinitions", validated.compiled.ObjectDefinitions).
344+
Interface("caveatDefinitions", validated.compiled.CaveatDefinitions).
345+
Object("addedOrChangedObjectDefinitions", validated.newObjectDefNames).
346+
Object("removedObjectDefinitions", removedObjectDefNames).
347+
Object("addedOrChangedCaveatDefinitions", validated.newCaveatDefNames).
348+
Object("removedCaveatDefinitions", removedCaveatDefNames).
349+
Bool("dryRun", true).
350+
Msg("completed schema update validation (dry run)")
351+
352+
return &AppliedSchemaChanges{
353+
TotalOperationCount: len(validated.compiled.ObjectDefinitions) + len(validated.compiled.CaveatDefinitions) + removedObjectDefNames.Len() + removedCaveatDefNames.Len(),
354+
NewObjectDefNames: validated.newObjectDefNames.Subtract(existingObjectDefNames).AsSlice(),
355+
RemovedObjectDefNames: removedObjectDefNames.AsSlice(),
356+
NewCaveatDefNames: validated.newCaveatDefNames.Subtract(existingCaveatDefNames).AsSlice(),
357+
RemovedCaveatDefNames: removedCaveatDefNames.AsSlice(),
358+
}, nil
359+
}
360+
240361
// sanityCheckCaveatChanges ensures that a caveat definition being written does not break
241362
// the types of the parameters that may already exist on relationships.
242363
func sanityCheckCaveatChanges(
@@ -284,6 +405,24 @@ func ensureNoRelationshipsExistWithResourceType(ctx context.Context, rwt datasto
284405
)
285406
}
286407

408+
// ensureNoRelationshipsExistWithResourceTypeDryRun ensures that no relationships exist within the namespace
409+
// with the given name as a resource type, using only read operations.
410+
func ensureNoRelationshipsExistWithResourceTypeDryRun(ctx context.Context, reader datastore.Reader, namespaceName string) error {
411+
qy, qyErr := reader.QueryRelationships(
412+
ctx,
413+
datastore.RelationshipsFilter{OptionalResourceType: namespaceName},
414+
options.WithLimit(options.LimitOne),
415+
options.WithQueryShape(queryshape.FindResourceOfType),
416+
)
417+
return errorIfTupleIteratorReturnsTuples(
418+
ctx,
419+
qy,
420+
qyErr,
421+
"cannot delete object definition `%s`, as a relationship exists under it",
422+
namespaceName,
423+
)
424+
}
425+
287426
// sanityCheckNamespaceChanges ensures that a namespace definition being written does not result
288427
// in breaking changes, such as relationships without associated defined schema object definitions
289428
// and relations.
@@ -451,3 +590,153 @@ func errorIfTupleIteratorReturnsTuples(_ context.Context, qy datastore.Relations
451590

452591
return nil
453592
}
593+
594+
// sanityCheckNamespaceChangesDryRun performs the same validation as sanityCheckNamespaceChanges
595+
// but using only read operations from a datastore.Reader.
596+
func sanityCheckNamespaceChangesDryRun(
597+
ctx context.Context,
598+
reader datastore.Reader,
599+
nsdef *core.NamespaceDefinition,
600+
existingDefs map[string]*core.NamespaceDefinition,
601+
) (*nsdiff.Diff, error) {
602+
// Ensure that the updated namespace does not break the existing tuple data.
603+
existing := existingDefs[nsdef.Name]
604+
diff, err := nsdiff.DiffNamespaces(existing, nsdef)
605+
if err != nil {
606+
return nil, err
607+
}
608+
609+
for _, delta := range diff.Deltas() {
610+
switch delta.Type {
611+
case nsdiff.RemovedRelation:
612+
// NOTE: We add the subject filters here to ensure the reverse relationship index is used
613+
// by the datastores. As there is no index that has {namespace, relation} directly, but there
614+
// *is* an index that has {subject_namespace, subject_relation, namespace, relation}, we can
615+
// force the datastore to use the reverse index by adding the subject filters.
616+
var previousRelation *core.Relation
617+
for _, relation := range existing.Relation {
618+
if relation.Name == delta.RelationName {
619+
previousRelation = relation
620+
break
621+
}
622+
}
623+
624+
if previousRelation == nil {
625+
return nil, spiceerrors.MustBugf("relation `%s` not found in existing namespace definition", delta.RelationName)
626+
}
627+
628+
subjectSelectors := make([]datastore.SubjectsSelector, 0, len(previousRelation.TypeInformation.AllowedDirectRelations))
629+
for _, allowedType := range previousRelation.TypeInformation.AllowedDirectRelations {
630+
if allowedType.GetRelation() == datastore.Ellipsis {
631+
subjectSelectors = append(subjectSelectors, datastore.SubjectsSelector{
632+
OptionalSubjectType: allowedType.Namespace,
633+
RelationFilter: datastore.SubjectRelationFilter{
634+
IncludeEllipsisRelation: true,
635+
},
636+
})
637+
} else {
638+
subjectSelectors = append(subjectSelectors, datastore.SubjectsSelector{
639+
OptionalSubjectType: allowedType.Namespace,
640+
RelationFilter: datastore.SubjectRelationFilter{
641+
NonEllipsisRelation: allowedType.GetRelation(),
642+
},
643+
})
644+
}
645+
}
646+
647+
qy, qyErr := reader.QueryRelationships(
648+
ctx,
649+
datastore.RelationshipsFilter{
650+
OptionalResourceType: nsdef.Name,
651+
OptionalResourceRelation: delta.RelationName,
652+
OptionalSubjectsSelectors: subjectSelectors,
653+
},
654+
options.WithLimit(options.LimitOne),
655+
options.WithQueryShape(queryshape.FindResourceOfTypeAndRelation),
656+
)
657+
658+
err = errorIfTupleIteratorReturnsTuples(
659+
ctx,
660+
qy,
661+
qyErr,
662+
"cannot delete relation `%s` in object definition `%s`, as a relationship exists under it", delta.RelationName, nsdef.Name)
663+
if err != nil {
664+
return diff, err
665+
}
666+
667+
// Also check for right sides of tuples.
668+
qy, qyErr = reader.ReverseQueryRelationships(
669+
ctx,
670+
datastore.SubjectsFilter{
671+
SubjectType: nsdef.Name,
672+
RelationFilter: datastore.SubjectRelationFilter{
673+
NonEllipsisRelation: delta.RelationName,
674+
},
675+
},
676+
options.WithLimitForReverse(options.LimitOne),
677+
options.WithQueryShapeForReverse(queryshape.FindSubjectOfTypeAndRelation),
678+
)
679+
err = errorIfTupleIteratorReturnsTuples(
680+
ctx,
681+
qy,
682+
qyErr,
683+
"cannot delete relation `%s` in object definition `%s`, as a relationship references it", delta.RelationName, nsdef.Name)
684+
if err != nil {
685+
return diff, err
686+
}
687+
688+
case nsdiff.RelationAllowedTypeRemoved:
689+
var optionalSubjectIds []string
690+
var relationFilter datastore.SubjectRelationFilter
691+
var optionalCaveatNameFilter datastore.CaveatNameFilter
692+
693+
if delta.AllowedType.GetPublicWildcard() != nil {
694+
optionalSubjectIds = []string{tuple.PublicWildcard}
695+
} else {
696+
relationFilter = datastore.SubjectRelationFilter{
697+
NonEllipsisRelation: delta.AllowedType.GetRelation(),
698+
}
699+
}
700+
701+
if delta.AllowedType.GetRequiredCaveat() != nil && delta.AllowedType.GetRequiredCaveat().CaveatName != "" {
702+
optionalCaveatNameFilter = datastore.WithCaveatName(delta.AllowedType.GetRequiredCaveat().CaveatName)
703+
} else {
704+
optionalCaveatNameFilter = datastore.WithNoCaveat()
705+
}
706+
707+
expirationOption := datastore.ExpirationFilterOptionNoExpiration
708+
if delta.AllowedType.RequiredExpiration != nil {
709+
expirationOption = datastore.ExpirationFilterOptionHasExpiration
710+
}
711+
712+
qyr, qyrErr := reader.QueryRelationships(
713+
ctx,
714+
datastore.RelationshipsFilter{
715+
OptionalResourceType: nsdef.Name,
716+
OptionalResourceRelation: delta.RelationName,
717+
OptionalSubjectsSelectors: []datastore.SubjectsSelector{
718+
{
719+
OptionalSubjectType: delta.AllowedType.Namespace,
720+
OptionalSubjectIds: optionalSubjectIds,
721+
RelationFilter: relationFilter,
722+
},
723+
},
724+
OptionalCaveatNameFilter: optionalCaveatNameFilter,
725+
OptionalExpirationOption: expirationOption,
726+
},
727+
options.WithLimit(options.LimitOne),
728+
options.WithQueryShape(queryshape.FindResourceRelationForSubjectRelation),
729+
)
730+
err = errorIfTupleIteratorReturnsTuples(
731+
ctx,
732+
qyr,
733+
qyrErr,
734+
"cannot remove allowed type `%s` from relation `%s` in object definition `%s`, as a relationship exists with it",
735+
schema.SourceForAllowedRelation(delta.AllowedType), delta.RelationName, nsdef.Name)
736+
if err != nil {
737+
return diff, err
738+
}
739+
}
740+
}
741+
return diff, nil
742+
}

internal/services/v1/schema.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (ss *schemaServer) ReadSchema(ctx context.Context, _ *v1.ReadSchemaRequest)
138138
func (ss *schemaServer) WriteSchema(ctx context.Context, in *v1.WriteSchemaRequest) (*v1.WriteSchemaResponse, error) {
139139
perfinsights.SetInContext(ctx, perfinsights.NoLabels)
140140

141-
log.Ctx(ctx).Trace().Str("schema", in.GetSchema()).Msg("requested Schema to be written")
141+
log.Ctx(ctx).Trace().Str("schema", in.GetSchema()).Bool("dryRun", in.GetDryRun()).Msg("requested Schema to be written")
142142

143143
ds := datastoremw.MustFromContext(ctx)
144144

@@ -165,7 +165,38 @@ func (ss *schemaServer) WriteSchema(ctx context.Context, in *v1.WriteSchemaReque
165165
return nil, ss.rewriteError(ctx, err)
166166
}
167167

168-
// Update the schema.
168+
if in.GetDryRun() {
169+
headRevision, err := ds.HeadRevision(ctx)
170+
if err != nil {
171+
return nil, ss.rewriteError(ctx, err)
172+
}
173+
174+
reader := ds.SnapshotReader(headRevision)
175+
176+
applied, err := shared.ApplySchemaChangesDryRun(ctx, reader, ss.caveatTypeSet, validated)
177+
if err != nil {
178+
return nil, ss.rewriteError(ctx, err)
179+
}
180+
181+
dispatchCount, err := genutil.EnsureUInt32(applied.TotalOperationCount)
182+
if err != nil {
183+
return nil, ss.rewriteError(ctx, err)
184+
}
185+
186+
usagemetrics.SetInContext(ctx, &dispatchv1.ResponseMeta{
187+
DispatchCount: dispatchCount,
188+
})
189+
190+
log.Ctx(ctx).Info().
191+
Interface("appliedChanges", applied).
192+
Msg("dry run validation completed successfully")
193+
194+
// Return the current head revision since we didn't actually write anything
195+
return &v1.WriteSchemaResponse{
196+
WrittenAt: zedtoken.MustNewFromRevision(headRevision),
197+
}, nil
198+
}
199+
169200
revision, err := ds.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error {
170201
applied, err := shared.ApplySchemaChanges(ctx, rwt, ss.caveatTypeSet, validated)
171202
if err != nil {

0 commit comments

Comments
 (0)