Skip to content

Commit cd31e33

Browse files
authored
usage reporting: fix memory leak (#7000)
A data structure in the usage reporting plugin would get a new entry each time the server's schema changed. Old entries would never be deleted, though their values would be shrunk to a minimum size. It seems that this was not good enough and is producing notable memory leaks for some folks. So let's fix it! The reason for the old behavior was to be really careful to never write to a report that may have already been taken out of the map and sent. Previously this was accomplished by having the main entry in the map never change. Now this is accomplished by making sure that we never write to the report any later than immediately (and synchronously) after we pull it out of the map. Fixes #6983. Backport from #6998.
1 parent c367260 commit cd31e33

File tree

2 files changed

+58
-45
lines changed

2 files changed

+58
-45
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ The version headers in this history reflect the versions of Apollo Server itself
1010

1111
## vNEXT
1212

13+
## v3.10.3
14+
- `apollo-server-core`: Fix memory leak in usage reporting plugin. [Issue #6983](https://github.com/apollographql/apollo-server/issues/6983) [PR #6999](https://github.com/apollographql/apollo-server/[Issue #6983](https://github.com/apollographql/apollo-server/issues/6983)pull/6999)
15+
1316
## v3.10.2
1417

1518
- `apollo-server-fastify`: Use `return reply.send` in handlers to match the pattern encouraged by Fastify 4 (although [`apollo-server-fastify@3` only works with Fastify 3](https://github.com/apollographql/apollo-server/issues/6576#issuecomment-1159249244)). [PR #6798](https://github.com/apollographql/apollo-server/pull/6798)

packages/apollo-server-core/src/plugin/usageReporting/plugin.ts

+55-45
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,6 @@ const reportHeaderDefaults = {
4848
uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`,
4949
};
5050

51-
class ReportData {
52-
report!: OurReport;
53-
readonly header: ReportHeader;
54-
constructor(executableSchemaId: string, graphRef: string) {
55-
this.header = new ReportHeader({
56-
...reportHeaderDefaults,
57-
executableSchemaId,
58-
graphRef,
59-
});
60-
this.reset();
61-
}
62-
reset() {
63-
this.report = new OurReport(this.header);
64-
}
65-
}
66-
6751
export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
6852
options: ApolloServerPluginUsageReportingOptions<TContext> = Object.create(
6953
null,
@@ -145,9 +129,45 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
145129
cache: LRUCache<string, OperationDerivedData>;
146130
} | null = null;
147131

148-
const reportDataByExecutableSchemaId: {
149-
[executableSchemaId: string]: ReportData | undefined;
150-
} = Object.create(null);
132+
// This map maps from executable schema ID (schema hash, basically) to the
133+
// report we'll send about it. That's because when we're using a gateway,
134+
// the schema can change over time, but each report needs to be about a
135+
// single schema. We avoid having this function be a memory leak by
136+
// removing values from it when we're in the process of sending reports.
137+
// That means we have to be very careful never to pull a Report out of it
138+
// and hang on to it for a while before writing to it, because the report
139+
// might have gotten sent and discarded in the meantime. So you should
140+
// only access the values of this Map via
141+
// getReportWhichMustBeUsedImmediately and getAndDeleteReport, and never
142+
// hang on to the value returned by getReportWhichMustBeUsedImmediately.
143+
const reportByExecutableSchemaId = new Map<string, OurReport>();
144+
const getReportWhichMustBeUsedImmediately = (
145+
executableSchemaId: string,
146+
): OurReport => {
147+
const existing = reportByExecutableSchemaId.get(executableSchemaId);
148+
if (existing) {
149+
return existing;
150+
}
151+
const report = new OurReport(
152+
new ReportHeader({
153+
...reportHeaderDefaults,
154+
executableSchemaId,
155+
graphRef,
156+
}),
157+
);
158+
reportByExecutableSchemaId.set(executableSchemaId, report);
159+
return report;
160+
};
161+
const getAndDeleteReport = (
162+
executableSchemaId: string,
163+
): OurReport | null => {
164+
const report = reportByExecutableSchemaId.get(executableSchemaId);
165+
if (report) {
166+
reportByExecutableSchemaId.delete(executableSchemaId);
167+
return report;
168+
}
169+
return null;
170+
};
151171

152172
const overriddenExecutableSchemaId = options.overrideReportedSchema
153173
? computeCoreSchemaHash(options.overrideReportedSchema)
@@ -193,21 +213,10 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
193213
return id;
194214
}
195215

196-
const getReportData = (executableSchemaId: string): ReportData => {
197-
const existing = reportDataByExecutableSchemaId[executableSchemaId];
198-
if (existing) {
199-
return existing;
200-
}
201-
const reportData = new ReportData(executableSchemaId, graphRef);
202-
reportDataByExecutableSchemaId[executableSchemaId] = reportData;
203-
return reportData;
204-
};
205-
206216
async function sendAllReportsAndReportErrors(): Promise<void> {
207217
await Promise.all(
208-
Object.keys(reportDataByExecutableSchemaId).map(
209-
(executableSchemaId) =>
210-
sendReportAndReportErrors(executableSchemaId),
218+
[...reportByExecutableSchemaId.keys()].map((executableSchemaId) =>
219+
sendReportAndReportErrors(executableSchemaId),
211220
),
212221
);
213222
}
@@ -229,13 +238,11 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
229238

230239
// Needs to be an arrow function to be confident that key is defined.
231240
const sendReport = async (executableSchemaId: string): Promise<void> => {
232-
const reportData = getReportData(executableSchemaId);
233-
const { report } = reportData;
234-
reportData.reset();
235-
241+
const report = getAndDeleteReport(executableSchemaId);
236242
if (
237-
Object.keys(report.tracesPerQuery).length === 0 &&
238-
report.operationCount === 0
243+
!report ||
244+
(Object.keys(report.tracesPerQuery).length === 0 &&
245+
report.operationCount === 0)
239246
) {
240247
return;
241248
}
@@ -580,10 +587,11 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
580587
const executableSchemaId =
581588
overriddenExecutableSchemaId ??
582589
executableSchemaIdForSchema(schema);
583-
const reportData = getReportData(executableSchemaId);
584590

585591
if (includeOperationInUsageReporting === false) {
586-
if (resolvedOperation) reportData.report.operationCount++;
592+
if (resolvedOperation)
593+
getReportWhichMustBeUsedImmediately(executableSchemaId)
594+
.operationCount++;
587595
return;
588596
}
589597

@@ -638,8 +646,6 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
638646
overriddenExecutableSchemaId ??
639647
executableSchemaIdForSchema(schema);
640648

641-
const reportData = getReportData(executableSchemaId);
642-
const { report } = reportData;
643649
const { trace } = treeBuilder;
644650

645651
let statsReportKey: string | undefined = undefined;
@@ -677,9 +683,12 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
677683
throw new Error(`Error encoding trace: ${protobufError}`);
678684
}
679685

680-
if (resolvedOperation) report.operationCount++;
686+
if (resolvedOperation) {
687+
getReportWhichMustBeUsedImmediately(executableSchemaId)
688+
.operationCount++;
689+
}
681690

682-
report.addTrace({
691+
getReportWhichMustBeUsedImmediately(executableSchemaId).addTrace({
683692
statsReportKey,
684693
trace,
685694
// We include the operation as a trace (rather than aggregated
@@ -705,7 +714,8 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
705714
// If the buffer gets big (according to our estimate), send.
706715
if (
707716
sendReportsImmediately ||
708-
report.sizeEstimator.bytes >=
717+
getReportWhichMustBeUsedImmediately(executableSchemaId)
718+
.sizeEstimator.bytes >=
709719
(options.maxUncompressedReportSize || 4 * 1024 * 1024)
710720
) {
711721
await sendReportAndReportErrors(executableSchemaId);

0 commit comments

Comments
 (0)