Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: check total request count against all top operations #6358

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-meals-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hive': patch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know where Changesets gets the name hive from?

CleanShot 2025-01-15 at 14 36 32@2x

I am confused because the package name is @hive/api.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's in deployment. We don't version packages/services/*, we version Hive as a whole package.

---

Use sum instead of max of top request counts for breaking changes calculation
129 changes: 129 additions & 0 deletions integration-tests/tests/api/target/usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2509,6 +2509,135 @@ test.concurrent(
},
);

test.concurrent(
'test threshold when using conditional breaking change "REQUEST_COUNT" detection, across multiple operations',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createTargetAccessToken, toggleTargetValidation, updateTargetValidationSettings } =
await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
await toggleTargetValidation(true);
await updateTargetValidationSettings({
excludedClients: [],
requestCount: 2,
percentage: 0,
breakingChangeFormula: BreakingChangeFormula.RequestCount,
});

const sdl = /* GraphQL */ `
type Query {
a: String
b: String
c: String
}
`;

const queryA = parse(/* GraphQL */ `
query {
a
}
`);
const queryB = parse(/* GraphQL */ `
query {
a
b
}
`);

function collectA() {
client.collectUsage()(
{
document: queryA,
schema,
contextValue: {
request,
},
},
{},
);
}
function collectB() {
client.collectUsage()(
{
document: queryB,
schema,
contextValue: {
request,
},
},
{},
);
}

const schema = buildASTSchema(parse(sdl));

const schemaPublishResult = await token
.publishSchema({
sdl,
author: 'Kamil',
commit: 'initial',
})
.then(res => res.expectNoGraphQLErrors());

expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');

const usageAddress = await getServiceHost('usage', 8081);

const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});

const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'integration-tests',
'x-graphql-client-version': '6.6.6',
},
});

collectA();
collectB();

await waitFor(8000);

// try to remove `Query.a`
const above = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());

if (above.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${above.schemaCheck.__typename}`);
}

expect(above.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Query'",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without your change, is it the case that 'a' would be allowed to be removed when it should not be?

},
],
total: 1,
});
},
);

test.concurrent(
'subscription operation is used for conditional breaking change detection',
async ({ expect }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,6 @@ export class OperationsReader {
excludedClients: null | readonly string[];
period: DateRange;
schemaCoordinates: string[];
requestCountThreshold: number;
}) {
const RecordArrayType = z.array(
z.object({
Expand Down Expand Up @@ -987,7 +986,7 @@ export class OperationsReader {
AND "coordinates_daily"."timestamp" >= toDateTime(${formatDate(args.period.from)}, 'UTC')
AND "coordinates_daily"."timestamp" <= toDateTime(${formatDate(args.period.to)}, 'UTC')
AND "coordinates_daily"."coordinate" IN (${sql.longArray(args.schemaCoordinates, 'String')})
HAVING "total" >= ${String(args.requestCountThreshold)}
HAVING "total" >= 1
ORDER BY
"total" DESC,
"coordinates_daily"."hash" DESC
Expand Down Expand Up @@ -1050,33 +1049,42 @@ export class OperationsReader {
}

/** Get the top operations for a given schema coordinate (uses batch loader underneath). */
getTopOperationsForSchemaCoordinate = batchBy<
{
targetIds: readonly string[];
excludedClients: null | readonly string[];
period: DateRange;
schemaCoordinate: string;
requestCountThreshold: number;
},
Array<{
hash: string;
name: string;
count: number;
}> | null
>(
item =>
`${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}-${item.requestCountThreshold}`,
async items => {
const schemaCoordinates = items.map(item => item.schemaCoordinate);
return await this._getTopOperationsForSchemaCoordinates({
targetIds: items[0].targetIds,
excludedClients: items[0].excludedClients,
period: items[0].period,
requestCountThreshold: items[0].requestCountThreshold,
schemaCoordinates,
}).then(result => result.map(result => Promise.resolve(result)));
},
);
getTopOperationsForSchemaCoordinate = async (selector: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This was a property initializer before, presumably because a higher order function was being used. Now that it is not, would it be better to revert to a class method like other members of this class?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aside: This looks like a case where function composition would have helped. A TypeScript library called Effect excels at that fwiw. A whole other conversation of course.

targetIds: readonly string[];
excludedClients: null | readonly string[];
period: DateRange;
schemaCoordinate: string;
requestCountThreshold: number;
}) => {
const result = await batchBy<
typeof selector,
Array<{
hash: string;
name: string;
count: number;
}> | null
>(
item =>
`${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}-${item.requestCountThreshold}`,
async items => {
const schemaCoordinates = items.map(item => item.schemaCoordinate);
return await this._getTopOperationsForSchemaCoordinates({
targetIds: items[0].targetIds,
excludedClients: items[0].excludedClients,
period: items[0].period,
schemaCoordinates,
}).then(result => result.map(result => Promise.resolve(result)));
},
)(selector);

if (
result &&
result.reduce((acc, { count }) => acc + count, 0) >= selector.requestCountThreshold
) {
return result;
}
return null;
};

/** Result array retains the order of the input `args.schemaCoordinates`. */
private async _getTopClientsForSchemaCoordinates(args: {
Expand Down
Loading