From d5dddb2e7d3138bb3c9c545a789354da5bce49f8 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 17 Jul 2026 08:16:58 +0200 Subject: [PATCH] feat: schema delete retries --- .changeset/late-bats-study.md | 6 + .../cli/src/commands/schema/delete.ts | 107 ++-- .../api/src/modules/schema/module.graphql.ts | 13 +- .../schema/providers/schema-publisher.ts | 492 +++++++++--------- .../schema/resolvers/Mutation/schemaDelete.ts | 5 + 5 files changed, 340 insertions(+), 283 deletions(-) create mode 100644 .changeset/late-bats-study.md diff --git a/.changeset/late-bats-study.md b/.changeset/late-bats-study.md new file mode 100644 index 00000000000..45a70edff91 --- /dev/null +++ b/.changeset/late-bats-study.md @@ -0,0 +1,6 @@ +--- +'@graphql-hive/cli': minor +'hive': minor +--- + +Support retries for deleting a schema in case the registry is busy and locked. diff --git a/packages/libraries/cli/src/commands/schema/delete.ts b/packages/libraries/cli/src/commands/schema/delete.ts index 6a9e8207ac1..8c3f54e83b5 100644 --- a/packages/libraries/cli/src/commands/schema/delete.ts +++ b/packages/libraries/cli/src/commands/schema/delete.ts @@ -1,6 +1,6 @@ import { Args, Errors, Flags, ux } from '@oclif/core'; import Command from '../../base-command'; -import { graphql, useFragment } from '../../gql'; +import { DocumentType, graphql, useFragment } from '../../gql'; import * as GraphQLSchema from '../../gql/graphql'; import { graphqlEndpoint } from '../../helpers/config'; import { @@ -37,6 +37,9 @@ const schemaDeleteMutation = graphql(/* GraphQL */ ` ...RenderErrors_SchemaErrorConnectionFragment } } + ... on SchemaDeleteRetry { + reason + } } } `); @@ -145,60 +148,68 @@ export default class SchemaDelete extends Command { target = result.data; } - const result = await this.registryApi(endpoint, accessToken).request({ - operation: schemaDeleteMutation, - variables: { - input: { - serviceName: service, - dryRun: flags.dryRun, - target, + let result: DocumentType | null = null; + + do { + result = await this.registryApi(endpoint, accessToken).request({ + operation: schemaDeleteMutation, + variables: { + input: { + serviceName: service, + dryRun: flags.dryRun, + target, + supportsRetry: true, + }, }, - }, - }); - - if (result.schemaDelete.__typename === 'SchemaDeleteSuccess') { - const { errors, changes } = result.schemaDelete; + }); - if (errors) { - const unmaskedErrors = useFragment(RenderErrors_SchemaErrorConnectionFragment, errors); - if (unmaskedErrors.edges.length > 0) { - this.log(renderErrors(errors)); + if (result.schemaDelete.__typename === 'SchemaDeleteRetry') { + this.log(result.schemaDelete.reason); + this.log('Waiting for other schema registry actions to complete...'); + result = null; + } else if (result.schemaDelete.__typename === 'SchemaDeleteSuccess') { + const { errors, changes } = result.schemaDelete; + + if (errors) { + const unmaskedErrors = useFragment(RenderErrors_SchemaErrorConnectionFragment, errors); + if (unmaskedErrors.edges.length > 0) { + this.log(renderErrors(errors)); + } } - } - if (changes) { - const unmaskedChanges = useFragment(RenderChanges_SchemaChanges, changes); - if (unmaskedChanges.edges.length > 0) { - this.log(''); - this.log(renderChanges(changes)); + if (changes) { + const unmaskedChanges = useFragment(RenderChanges_SchemaChanges, changes); + if (unmaskedChanges.edges.length > 0) { + this.log(''); + this.log(renderChanges(changes)); + } + } + this.log(''); + + if (result.schemaDelete.valid) { + this.logSuccess( + flags.dryRun + ? `Deleting "${service}" will produce a composable graph. But be sure to review changes to ensure this is safe.` + : `Deleted "${service}" from target`, + ); + } else { + this.logWarning( + flags.dryRun + ? `Your graph will NOT be composable if "${service}" is deleted.` + : `Deleting "${service}" produced an uncomposable graph.`, + ); } - } - this.log(''); - - if (result.schemaDelete.valid) { - this.logSuccess( - flags.dryRun - ? `Deleting "${service}" will produce a composable graph. But be sure to review changes to ensure this is safe.` - : `Deleted "${service}" from target`, - ); - } else { - this.logWarning( - flags.dryRun - ? `Your graph will NOT be composable if "${service}" is deleted.` - : `Deleting "${service}" produced an uncomposable graph.`, - ); - } - - this.exit(0); - return; - } - this.logFailure(`Failed to delete "${service}"`); - const errors = result.schemaDelete.errors; + this.exit(0); + } else { + this.logFailure(`Failed to delete "${service}"`); + const errors = result.schemaDelete.errors; - if (errors) { - throw new APIError(renderErrors(errors)); - } + if (errors) { + throw new APIError(renderErrors(errors)); + } + } + } while (result === null); } catch (error) { if (error instanceof Errors.CLIError) { throw error; diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 8f40e11be24..22b668f4d15 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -429,7 +429,14 @@ export default gql` | GitHubSchemaCheckSuccess | GitHubSchemaCheckError - union SchemaDeleteResult = SchemaDeleteSuccess | SchemaDeleteError + union SchemaDeleteResult = SchemaDeleteSuccess | SchemaDeleteError | SchemaDeleteRetry + + type SchemaDeleteRetry { + """ + The reason for the retry. + """ + reason: String! + } type SchemaDeleteSuccess { valid: Boolean! @@ -887,6 +894,10 @@ export default gql` target: TargetReferenceInput serviceName: ID! dryRun: Boolean + """ + Whether the client supports retries in case the registry is busy and the operation could not be performed. + """ + supportsRetry: Boolean = false } input GitHubSchemaCheckInput { diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 9ee0fed8e19..56c6a4a5f07 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -1407,264 +1407,288 @@ export class SchemaPublisher { }, }); - return this.mutex.perform( - registryLockId(selector.targetId), - { - signal, - }, - async () => { - const [organization, project, target] = await Promise.all([ - this.storage.getOrganization({ - organizationId: selector.organizationId, - }), - this.storage.getProject({ - organizationId: selector.organizationId, - projectId: selector.projectId, - }), - this.storage.getTarget({ - organizationId: selector.organizationId, - projectId: selector.projectId, - targetId: selector.targetId, - }), - ]); + return this.mutex + .perform( + registryLockId(selector.targetId), + { + signal, + }, + async () => { + const [organization, project, target] = await Promise.all([ + this.storage.getOrganization({ + organizationId: selector.organizationId, + }), + this.storage.getProject({ + organizationId: selector.organizationId, + projectId: selector.projectId, + }), + this.storage.getTarget({ + organizationId: selector.organizationId, + projectId: selector.projectId, + targetId: selector.targetId, + }), + ]); - schemaDeleteCount.inc({ model: 'modern', projectType: project.type }); + schemaDeleteCount.inc({ model: 'modern', projectType: project.type }); - if (project.type !== ProjectType.FEDERATION && project.type !== ProjectType.STITCHING) { - throw new HiveError(`${project.type} project not supported`); - } + if (project.type !== ProjectType.FEDERATION && project.type !== ProjectType.STITCHING) { + throw new HiveError(`${project.type} project not supported`); + } - const [latestVersion, latestComposableVersion, baseSchema] = await Promise.all([ - this.schemaManager.getLatestSchemaVersionWithSchemaLogs({ - target, - }), - this.schemaManager.getLatestSchemaVersionWithSchemaLogs({ - target, - onlyComposable: true, - }), - this.storage.getBaseSchema({ - organizationId: selector.organizationId, - projectId: selector.projectId, - targetId: selector.targetId, - }), - ]); + const [latestVersion, latestComposableVersion, baseSchema] = await Promise.all([ + this.schemaManager.getLatestSchemaVersionWithSchemaLogs({ + target, + }), + this.schemaManager.getLatestSchemaVersionWithSchemaLogs({ + target, + onlyComposable: true, + }), + this.storage.getBaseSchema({ + organizationId: selector.organizationId, + projectId: selector.projectId, + targetId: selector.targetId, + }), + ]); - if (!latestVersion || latestVersion.schemas.length === 0) { - throw new HiveError('Registry is empty'); - } + if (!latestVersion || latestVersion.schemas.length === 0) { + throw new HiveError('Registry is empty'); + } - const schemas = ensureCompositeSchemas(latestVersion.schemas); - this.logger.debug(`Found ${latestVersion?.schemas.length ?? 0} most recent schemas`); - this.logger.debug( - 'Using %s registry model (featureFlags=%o)', - project.type, - organization.featureFlags, - ); + const schemas = ensureCompositeSchemas(latestVersion.schemas); + this.logger.debug(`Found ${latestVersion?.schemas.length ?? 0} most recent schemas`); + this.logger.debug( + 'Using %s registry model (featureFlags=%o)', + project.type, + organization.featureFlags, + ); - const affectedService = schemas.find(s => s.service_name === input.serviceName); + const affectedService = schemas.find(s => s.service_name === input.serviceName); - if (!affectedService) { - return { - __typename: 'SchemaDeleteError', - valid: false, - errors: [ - { - message: `Service "${input.serviceName}" not found`, - }, - ], - } as const; - } + if (!affectedService) { + return { + __typename: 'SchemaDeleteError', + valid: false, + errors: [ + { + message: `Service "${input.serviceName}" not found`, + }, + ], + } as const; + } - const { - conditionalBreakingChangeConfiguration, - failDiffOnDangerousChange, - failAllDangerousChanges, - failDangerousChangeTypes, - } = await this.getBreakingChangeConfiguration({ - selector: { - targetId: selector.targetId, - projectId: selector.projectId, - organizationId: selector.organizationId, - }, - }); + const { + conditionalBreakingChangeConfiguration, + failDiffOnDangerousChange, + failAllDangerousChanges, + failDangerousChangeTypes, + } = await this.getBreakingChangeConfiguration({ + selector: { + targetId: selector.targetId, + projectId: selector.projectId, + organizationId: selector.organizationId, + }, + }); - const contracts = - project.type === ProjectType.FEDERATION - ? await this.contracts.loadActiveContractsWithLatestValidContractVersionsByTargetId({ - targetId: selector.targetId, - }) - : null; + const contracts = + project.type === ProjectType.FEDERATION + ? await this.contracts.loadActiveContractsWithLatestValidContractVersionsByTargetId({ + targetId: selector.targetId, + }) + : null; - const deleteResult = await this.models[project.type].delete({ - input: { - serviceName: input.serviceName, - }, - latest: { - isComposable: latestVersion.version.isComposable, - sdl: latestVersion.version.compositeSchemaSDL, - schemas: schemas.map(toCompositeSchemaInput), - }, - latestComposable: latestComposableVersion - ? { - isComposable: latestComposableVersion.version.isComposable, - sdl: latestComposableVersion.version.compositeSchemaSDL ?? null, - supergraphSdl: latestComposableVersion.version.supergraphSDL, - schemas: ensureCompositeSchemas(latestComposableVersion.schemas).map( - toCompositeSchemaInput, - ), - } - : null, - baseSchema, - project, - organization, - selector: { - target: selector.targetId, - project: selector.projectId, - organization: selector.organizationId, - }, - conditionalBreakingChangeDiffConfig: - conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null, - contracts, - failDiffOnDangerousChange, - failAllDangerousChanges, - failDangerousChangeTypes, - }); + const deleteResult = await this.models[project.type].delete({ + input: { + serviceName: input.serviceName, + }, + latest: { + isComposable: latestVersion.version.isComposable, + sdl: latestVersion.version.compositeSchemaSDL, + schemas: schemas.map(toCompositeSchemaInput), + }, + latestComposable: latestComposableVersion + ? { + isComposable: latestComposableVersion.version.isComposable, + sdl: latestComposableVersion.version.compositeSchemaSDL ?? null, + supergraphSdl: latestComposableVersion.version.supergraphSDL, + schemas: ensureCompositeSchemas(latestComposableVersion.schemas).map( + toCompositeSchemaInput, + ), + } + : null, + baseSchema, + project, + organization, + selector: { + target: selector.targetId, + project: selector.projectId, + organization: selector.organizationId, + }, + conditionalBreakingChangeDiffConfig: + conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null, + contracts, + failDiffOnDangerousChange, + failAllDangerousChanges, + failDangerousChangeTypes, + }); - if (deleteResult.conclusion === SchemaDeleteConclusion.Accept) { - this.logger.debug('Delete accepted'); - if (input.dryRun !== true) { - const schemaVersion = await this.schemaVersions.deleteSubgraphFromTarget(target, { - service: { - name: affectedService.service_name, - versionId: affectedService.id, - }, - composable: deleteResult.state.composable, - diffSchemaVersionId: latestComposableVersion?.version.id ?? null, - changes: deleteResult.state.changes, - contracts: - deleteResult.state.contracts?.map(contract => ({ - contractId: contract.contractId, - contractName: contract.contractName, - compositeSchemaSDL: contract.fullSchemaSdl, - supergraphSDL: contract.supergraph, - schemaCompositionErrors: contract.compositionErrors, - changes: contract.changes, - })) ?? null, - ...(deleteResult.state.fullSchemaSdl - ? { - compositeSchemaSDL: deleteResult.state.fullSchemaSdl, - supergraphSDL: deleteResult.state.supergraph, - supergraphChanges: deleteResult.state.supergraphChanges, - schemaCompositionErrors: null, - tags: deleteResult.state.tags, - schemaMetadata: deleteResult.state.schemaMetadata, - metadataAttributes: deleteResult.state.metadataAttributes, - } - : { - compositeSchemaSDL: null, - supergraphSDL: null, - supergraphChanges: null, - schemaCompositionErrors: deleteResult.state.compositionErrors ?? [], - tags: null, - schemaMetadata: null, - metadataAttributes: null, - }), - actionFn: async (versionId: string) => { - if (deleteResult.state.composable) { - const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; - for (const contract of deleteResult.state.contracts ?? []) { - if (contract.fullSchemaSdl && contract.supergraph) { - contracts.push({ - name: contract.contractName, - sdl: contract.fullSchemaSdl, - supergraph: contract.supergraph, - }); + if (deleteResult.conclusion === SchemaDeleteConclusion.Accept) { + this.logger.debug('Delete accepted'); + if (input.dryRun !== true) { + const schemaVersion = await this.schemaVersions.deleteSubgraphFromTarget(target, { + service: { + name: affectedService.service_name, + versionId: affectedService.id, + }, + composable: deleteResult.state.composable, + diffSchemaVersionId: latestComposableVersion?.version.id ?? null, + changes: deleteResult.state.changes, + contracts: + deleteResult.state.contracts?.map(contract => ({ + contractId: contract.contractId, + contractName: contract.contractName, + compositeSchemaSDL: contract.fullSchemaSdl, + supergraphSDL: contract.supergraph, + schemaCompositionErrors: contract.compositionErrors, + changes: contract.changes, + })) ?? null, + ...(deleteResult.state.fullSchemaSdl + ? { + compositeSchemaSDL: deleteResult.state.fullSchemaSdl, + supergraphSDL: deleteResult.state.supergraph, + supergraphChanges: deleteResult.state.supergraphChanges, + schemaCompositionErrors: null, + tags: deleteResult.state.tags, + schemaMetadata: deleteResult.state.schemaMetadata, + metadataAttributes: deleteResult.state.metadataAttributes, + } + : { + compositeSchemaSDL: null, + supergraphSDL: null, + supergraphChanges: null, + schemaCompositionErrors: deleteResult.state.compositionErrors ?? [], + tags: null, + schemaMetadata: null, + metadataAttributes: null, + }), + actionFn: async (versionId: string) => { + if (deleteResult.state.composable) { + const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; + for (const contract of deleteResult.state.contracts ?? []) { + if (contract.fullSchemaSdl && contract.supergraph) { + contracts.push({ + name: contract.contractName, + sdl: contract.fullSchemaSdl, + supergraph: contract.supergraph, + }); + } } + + await this.publishToCDN({ + target, + project, + supergraph: deleteResult.state.supergraph, + fullSchemaSdl: deleteResult.state.fullSchemaSdl, + // pass all schemas except the one we are deleting + schemas: deleteResult.state.schemas, + contracts, + versionId, + }); } + }, + conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({ + conditionalBreakingChangeConfiguration, + organizationId: selector.organizationId, + projectId: selector.projectId, + targetId: selector.targetId, + }), + }); - await this.publishToCDN({ - target, + const changes = deleteResult.state.changes ?? []; + const errors = [ + ...(deleteResult.state.compositionErrors ?? []), + ...(deleteResult.state.breakingChanges ?? []).map(change => ({ + message: change.message, + // triggerSchemaChangeNotifications.errors accepts only path as array + path: change.path ? [change.path] : undefined, + })), + ]; + + if ((Array.isArray(changes) && changes.length > 0) || errors.length > 0) { + void this.alertsManager + .triggerSchemaChangeNotifications({ + organization, project, - supergraph: deleteResult.state.supergraph, - fullSchemaSdl: deleteResult.state.fullSchemaSdl, - // pass all schemas except the one we are deleting - schemas: deleteResult.state.schemas, - contracts, - versionId, + target, + schema: { + id: schemaVersion.versionId, + commit: schemaVersion.id, + valid: deleteResult.state.composable, + }, + changes, + messages: [], + errors, + initial: false, + }) + .catch(err => { + this.logger.error('Failed to trigger schema change notifications', err); }); - } - }, - conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({ - conditionalBreakingChangeConfiguration, - organizationId: selector.organizationId, - projectId: selector.projectId, - targetId: selector.targetId, - }), - }); - - const changes = deleteResult.state.changes ?? []; - const errors = [ - ...(deleteResult.state.compositionErrors ?? []), - ...(deleteResult.state.breakingChanges ?? []).map(change => ({ - message: change.message, - // triggerSchemaChangeNotifications.errors accepts only path as array - path: change.path ? [change.path] : undefined, - })), - ]; - - if ((Array.isArray(changes) && changes.length > 0) || errors.length > 0) { - void this.alertsManager - .triggerSchemaChangeNotifications({ - organization, - project, - target, - schema: { - id: schemaVersion.versionId, - commit: schemaVersion.id, - valid: deleteResult.state.composable, - }, - changes, - messages: [], - errors, - initial: false, - }) - .catch(err => { - this.logger.error('Failed to trigger schema change notifications', err); - }); + } } + + return { + __typename: 'SchemaDeleteSuccess', + valid: deleteResult.state.composable, + changes: deleteResult.state.changes, + errors: [ + ...(deleteResult.state.compositionErrors ?? []), + ...(deleteResult.state.breakingChanges ?? []), + ], + } as const; } - return { - __typename: 'SchemaDeleteSuccess', - valid: deleteResult.state.composable, - changes: deleteResult.state.changes, - errors: [ - ...(deleteResult.state.compositionErrors ?? []), - ...(deleteResult.state.breakingChanges ?? []), - ], - } as const; - } + this.logger.debug('Delete rejected'); - this.logger.debug('Delete rejected'); + const errors = []; - const errors = []; + const compositionErrors = getReasonByCode( + deleteResult.reasons, + DeleteFailureReasonCode.CompositionFailure, + )?.compositionErrors; - const compositionErrors = getReasonByCode( - deleteResult.reasons, - DeleteFailureReasonCode.CompositionFailure, - )?.compositionErrors; + if (compositionErrors?.length) { + errors.push(...compositionErrors); + } - if (compositionErrors?.length) { - errors.push(...compositionErrors); - } + return { + __typename: 'SchemaDeleteError', + valid: false, + errors, + } as const; + }, + ) + .catch((error: unknown) => { + if (error instanceof MutexResourceLockedError) { + if (input.supportsRetry === true) { + return { + __typename: 'SchemaDeleteRetry', + reason: 'Another schema operation is currently in progress.', + } as const; + } - return { - __typename: 'SchemaDeleteError', - valid: false, - errors, - } as const; - }, - ); + return { + __typename: 'SchemaDeleteError', + valid: false, + changes: [], + errors: [ + { + message: + 'Another schema operation is currently in progress. Please retry the delete.', + }, + ], + } as const; + } + throw error; + }); } private async internalPublish( diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaDelete.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaDelete.ts index 9ab7254db80..978219bc757 100644 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaDelete.ts +++ b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaDelete.ts @@ -11,10 +11,15 @@ export const schemaDelete: NonNullable = asyn dryRun: input.dryRun, serviceName: input.serviceName.toLowerCase(), target: input.target, + supportsRetry: input.supportsRetry, }, request.signal, ); + if (result.__typename === 'SchemaDeleteRetry') { + return result; + } + return { ...result, changes: result.changes,