Skip to content

Commit 233b44e

Browse files
authored
usage reporting: fix memory leak (#6998)
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.
1 parent f6c2991 commit 233b44e

File tree

2 files changed

+61
-45
lines changed

2 files changed

+61
-45
lines changed

.changeset/tasty-elephants-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@apollo/server': patch
3+
---
4+
5+
Fix a slow memory leak in the usage reporting plugin (#6983).

packages/server/src/plugin/usageReporting/plugin.ts

Lines changed: 56 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,6 @@ const reportHeaderDefaults = {
4646
uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`,
4747
};
4848

49-
class ReportData {
50-
report!: OurReport;
51-
readonly header: ReportHeader;
52-
constructor(executableSchemaId: string, graphRef: string) {
53-
this.header = new ReportHeader({
54-
...reportHeaderDefaults,
55-
executableSchemaId,
56-
graphRef,
57-
});
58-
this.reset();
59-
}
60-
reset() {
61-
this.report = new OurReport(this.header);
62-
}
63-
}
64-
6549
export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
6650
options: ApolloServerPluginUsageReportingOptions<TContext> = Object.create(
6751
null,
@@ -143,9 +127,45 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
143127
cache: LRUCache<string, OperationDerivedData>;
144128
} | null = null;
145129

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

150170
const overriddenExecutableSchemaId = options.overrideReportedSchema
151171
? computeCoreSchemaHash(options.overrideReportedSchema)
@@ -192,21 +212,10 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
192212
return id;
193213
}
194214

195-
const getReportData = (executableSchemaId: string): ReportData => {
196-
const existing = reportDataByExecutableSchemaId[executableSchemaId];
197-
if (existing) {
198-
return existing;
199-
}
200-
const reportData = new ReportData(executableSchemaId, graphRef);
201-
reportDataByExecutableSchemaId[executableSchemaId] = reportData;
202-
return reportData;
203-
};
204-
205215
async function sendAllReportsAndReportErrors(): Promise<void> {
206216
await Promise.all(
207-
Object.keys(reportDataByExecutableSchemaId).map(
208-
(executableSchemaId) =>
209-
sendReportAndReportErrors(executableSchemaId),
217+
[...reportByExecutableSchemaId.keys()].map((executableSchemaId) =>
218+
sendReportAndReportErrors(executableSchemaId),
210219
),
211220
);
212221
}
@@ -228,13 +237,11 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
228237

229238
// Needs to be an arrow function to be confident that key is defined.
230239
const sendReport = async (executableSchemaId: string): Promise<void> => {
231-
const reportData = getReportData(executableSchemaId);
232-
const { report } = reportData;
233-
reportData.reset();
234-
240+
const report = getAndDeleteReport(executableSchemaId);
235241
if (
236-
Object.keys(report.tracesPerQuery).length === 0 &&
237-
report.operationCount === 0
242+
!report ||
243+
(Object.keys(report.tracesPerQuery).length === 0 &&
244+
report.operationCount === 0)
238245
) {
239246
return;
240247
}
@@ -577,10 +584,12 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
577584
treeBuilder.stopTiming();
578585
const executableSchemaId =
579586
overriddenExecutableSchemaId ?? executableSchemaIdForSchema(schema);
580-
const reportData = getReportData(executableSchemaId);
581587

582588
if (includeOperationInUsageReporting === false) {
583-
if (resolvedOperation) reportData.report.operationCount++;
589+
if (resolvedOperation) {
590+
getReportWhichMustBeUsedImmediately(executableSchemaId)
591+
.operationCount++;
592+
}
584593
return;
585594
}
586595

@@ -634,8 +643,6 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
634643
overriddenExecutableSchemaId ??
635644
executableSchemaIdForSchema(schema);
636645

637-
const reportData = getReportData(executableSchemaId);
638-
const { report } = reportData;
639646
const { trace } = treeBuilder;
640647

641648
let statsReportKey: string | undefined = undefined;
@@ -673,9 +680,12 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
673680
throw new Error(`Error encoding trace: ${protobufError}`);
674681
}
675682

676-
if (resolvedOperation) report.operationCount++;
683+
if (resolvedOperation) {
684+
getReportWhichMustBeUsedImmediately(executableSchemaId)
685+
.operationCount++;
686+
}
677687

678-
report.addTrace({
688+
getReportWhichMustBeUsedImmediately(executableSchemaId).addTrace({
679689
statsReportKey,
680690
trace,
681691
// We include the operation as a trace (rather than aggregated
@@ -700,7 +710,8 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
700710
// If the buffer gets big (according to our estimate), send.
701711
if (
702712
sendReportsImmediately ||
703-
report.sizeEstimator.bytes >=
713+
getReportWhichMustBeUsedImmediately(executableSchemaId)
714+
.sizeEstimator.bytes >=
704715
(options.maxUncompressedReportSize || 4 * 1024 * 1024)
705716
) {
706717
await sendReportAndReportErrors(executableSchemaId);

0 commit comments

Comments
 (0)