Skip to content

Commit 87c67dc

Browse files
PijukatelCopilotbarjin
authored
feat: Dynamic memory snapshots (#3471)
### Description - Allow `Snapshotter` to take into account changes in total memory. - When fixed memory through `Configuration.memoryMbytes` is not set, then dynamic memory mode based on `Configuration.availableMemoryRatio` is used. If the event manager is sending sufficient information (total memory available in the system), then the `Snapshotter` is capable of scaling with the total available memory. (Otherwise, fall back to previous behavior - fixed memory based on initial measurement.) (Difference to Python implementation -> No dedicated Ratio type. Since in JS implementation, there is no user-facing change, there is also no need for this new type. Instead, the ratio is saved as private property of Snapshotter when needed.) ### Issues - Closes: #3408 ### Testing - Unit test ### Checklist - [ ] CI passed --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jindřich Bär <jindrichbar@gmail.com>
1 parent 9197295 commit 87c67dc

4 files changed

Lines changed: 131 additions & 45 deletions

File tree

packages/core/src/autoscaling/snapshotter.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export class Snapshotter {
127127
maxUsedMemoryRatio: number;
128128
maxClientErrors: number;
129129
maxMemoryBytes!: number;
130+
private maxMemoryRatio: number | undefined;
130131

131132
cpuSnapshots: CpuSnapshot[] = [];
132133
eventLoopSnapshots: EventLoopSnapshot[] = [];
@@ -180,7 +181,6 @@ export class Snapshotter {
180181
this.maxBlockedMillis = maxBlockedMillis;
181182
this.maxUsedMemoryRatio = maxUsedMemoryRatio;
182183
this.maxClientErrors = maxClientErrors;
183-
184184
// We need to pre-bind those functions to be able to successfully remove listeners.
185185
this._snapshotCpu = this._snapshotCpu.bind(this);
186186
this._snapshotMemory = this._snapshotMemory.bind(this);
@@ -195,22 +195,20 @@ export class Snapshotter {
195195
if (memoryMbytes > 0) {
196196
this.maxMemoryBytes = memoryMbytes * 1024 * 1024;
197197
} else {
198-
let totalBytes: number;
199-
200-
if (this.config.get('systemInfoV2')) {
201-
const containerized = this.config.get('containerized', await isContainerized());
202-
const memInfo = await getMemoryInfoV2(containerized);
203-
totalBytes = memInfo.totalBytes;
198+
this.maxMemoryRatio = this.config.get('availableMemoryRatio');
199+
if (!this.maxMemoryRatio) {
200+
throw new Error('availableMemoryRatio is not set in configuration.');
204201
} else {
205-
const memInfo = await getMemoryInfo();
206-
totalBytes = memInfo.totalBytes;
202+
this.log.debug(
203+
`Setting max memory of this run to ${this.maxMemoryRatio * 100} % of available memory. ` +
204+
'Use the CRAWLEE_MEMORY_MBYTES or CRAWLEE_AVAILABLE_MEMORY_RATIO environment variable to override it.',
205+
);
207206
}
208-
209-
this.maxMemoryBytes = Math.ceil(totalBytes * this.config.get('availableMemoryRatio')!);
210-
this.log.debug(
211-
`Setting max memory of this run to ${Math.round(this.maxMemoryBytes / 1024 / 1024)} MB. ` +
212-
'Use the CRAWLEE_MEMORY_MBYTES or CRAWLEE_AVAILABLE_MEMORY_RATIO environment variable to override it.',
213-
);
207+
// Create a fallback memory measurement in case of missing memTotalBytes in SystemInfo. Weak types of
208+
// SystemInfo do not guarantee that memTotalBytes is always present, and without it, we cannot compute the
209+
// maxMemoryBytes.
210+
// This does not happen in practice, but code allows it.
211+
this.maxMemoryBytes = await this.getTotalMemoryBytes();
214212
}
215213

216214
// Start snapshotting.
@@ -299,21 +297,27 @@ export class Snapshotter {
299297
protected _snapshotMemory(systemInfo: SystemInfo) {
300298
const createdAt = systemInfo.createdAt ? new Date(systemInfo.createdAt) : new Date();
301299
this._pruneSnapshots(this.memorySnapshots, createdAt);
302-
const { memCurrentBytes } = systemInfo;
300+
const { memCurrentBytes, memTotalBytes } = systemInfo;
301+
302+
let maxMemoryBytes = this.maxMemoryBytes!;
303+
if (this.maxMemoryRatio !== undefined && this.maxMemoryRatio > 0) {
304+
maxMemoryBytes = this.maxMemoryRatio * (memTotalBytes ?? this.maxMemoryBytes);
305+
}
306+
303307
const snapshot: MemorySnapshot = {
304308
createdAt,
305-
isOverloaded: memCurrentBytes! / this.maxMemoryBytes! > this.maxUsedMemoryRatio,
309+
isOverloaded: memCurrentBytes! / maxMemoryBytes > this.maxUsedMemoryRatio,
306310
usedBytes: memCurrentBytes,
307311
};
308312

309313
this.memorySnapshots.push(snapshot);
310-
this._memoryOverloadWarning(systemInfo);
314+
this._memoryOverloadWarning(systemInfo, maxMemoryBytes);
311315
}
312316

313317
/**
314318
* Checks for critical memory overload and logs it to the console.
315319
*/
316-
protected _memoryOverloadWarning(systemInfo: SystemInfo) {
320+
protected _memoryOverloadWarning(systemInfo: SystemInfo, maxMemoryBytes: number) {
317321
const { memCurrentBytes } = systemInfo;
318322
const createdAt = systemInfo.createdAt ? new Date(systemInfo.createdAt) : new Date();
319323
if (
@@ -322,18 +326,18 @@ export class Snapshotter {
322326
)
323327
return;
324328

325-
const maxDesiredMemoryBytes = this.maxUsedMemoryRatio * this.maxMemoryBytes!;
326-
const reserveMemory = this.maxMemoryBytes! * (1 - this.maxUsedMemoryRatio) * RESERVE_MEMORY_RATIO;
329+
const maxDesiredMemoryBytes = this.maxUsedMemoryRatio * maxMemoryBytes;
330+
const reserveMemory = maxMemoryBytes * (1 - this.maxUsedMemoryRatio) * RESERVE_MEMORY_RATIO;
327331
const criticalOverloadBytes = maxDesiredMemoryBytes + reserveMemory;
328332
const isCriticalOverload = memCurrentBytes! > criticalOverloadBytes;
329333

330334
if (isCriticalOverload) {
331-
const usedPercentage = Math.round((memCurrentBytes! / this.maxMemoryBytes!) * 100);
335+
const usedPercentage = Math.round((memCurrentBytes! / maxMemoryBytes) * 100);
332336
const toMb = (bytes: number) => Math.round(bytes / 1024 ** 2);
333337
this.log.warning(
334338
'Memory is critically overloaded. ' +
335339
`Using ${toMb(memCurrentBytes!)} MB of ${toMb(
336-
this.maxMemoryBytes!,
340+
maxMemoryBytes,
337341
)} MB (${usedPercentage}%). Consider increasing available memory.`,
338342
);
339343
this.lastLoggedCriticalMemoryOverloadAt = createdAt;
@@ -429,4 +433,12 @@ export class Snapshotter {
429433
}
430434
snapshots.splice(0, oldCount);
431435
}
436+
437+
protected async getTotalMemoryBytes() {
438+
if (this.config.get('systemInfoV2')) {
439+
const containerized = this.config.get('containerized', await isContainerized());
440+
return (await getMemoryInfoV2(containerized)).totalBytes;
441+
}
442+
return (await getMemoryInfo()).totalBytes;
443+
}
432444
}

packages/core/src/autoscaling/system_status.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface SystemInfo {
1414
eventLoopInfo: ClientInfo;
1515
cpuInfo: ClientInfo;
1616
clientInfo: ClientInfo;
17+
memTotalBytes?: number;
1718
memCurrentBytes?: number;
1819
/**
1920
* Platform only property

packages/core/src/events/local_event_manager.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,21 @@ export class LocalEventManager extends EventManager {
104104

105105
private async createMemoryInfo() {
106106
try {
107-
if (this.config.get('systemInfoV2')) {
108-
const memInfo = await getMemoryInfoV2(await this.isContainerizedWrapper());
109-
return {
110-
memCurrentBytes: memInfo.mainProcessBytes + memInfo.childProcessesBytes,
111-
};
112-
}
113-
const memInfo = await getMemoryInfo();
107+
const memInfo = await this.getMemoryInfo();
114108
return {
109+
memTotalBytes: memInfo.totalBytes,
115110
memCurrentBytes: memInfo.mainProcessBytes + memInfo.childProcessesBytes,
116111
};
117112
} catch (err) {
118113
log.exception(err as Error, 'Memory snapshot failed.');
119114
return {};
120115
}
121116
}
117+
118+
private async getMemoryInfo() {
119+
if (this.config.get('systemInfoV2')) {
120+
return getMemoryInfoV2(await this.isContainerizedWrapper());
121+
}
122+
return getMemoryInfo();
123+
}
122124
}

test/core/autoscaling/snapshotter.test.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import os from 'node:os';
22

33
import { Configuration, EventType, LocalEventManager, Snapshotter } from '@crawlee/core';
44
import type { MemoryInfo } from '@crawlee/utils';
5-
import * as utils from '@crawlee/utils';
65
import { sleep } from '@crawlee/utils';
76

87
import log from '@apify/log';
98

109
const toBytes = (x: number) => x * 1024 * 1024;
10+
const noop = () => {};
1111

1212
describe('Snapshotter', () => {
1313
let logLevel: number;
@@ -139,7 +139,6 @@ describe('Snapshotter', () => {
139139

140140
cpusMock.mockReturnValue(fakeCpu as any);
141141

142-
const noop = () => {};
143142
const config = new Configuration({ maxUsedCpuRatio: 0.5 });
144143
const snapshotter = new Snapshotter({ config });
145144
// do not initialize the event intervals as we will fire them manually
@@ -178,7 +177,6 @@ describe('Snapshotter', () => {
178177
test('correctly marks eventLoopOverloaded', () => {
179178
const clock = vitest.useFakeTimers();
180179
try {
181-
const noop = () => {};
182180
const snapshotter = new Snapshotter({ maxBlockedMillis: 5, eventLoopSnapshotIntervalSecs: 0 });
183181
// @ts-expect-error Calling protected method
184182
snapshotter._snapshotEventLoop(noop);
@@ -208,13 +206,12 @@ describe('Snapshotter', () => {
208206
});
209207

210208
test('correctly marks memoryOverloaded using OS metrics', async () => {
211-
const noop = () => {};
212209
const memoryData = {
213210
totalBytes: toBytes(10000),
214211
mainProcessBytes: toBytes(1000),
215212
childProcessesBytes: toBytes(1000),
216213
} as MemoryInfo;
217-
vitest.spyOn(utils, 'getMemoryInfoV2').mockResolvedValue(memoryData);
214+
vitest.spyOn(LocalEventManager.prototype as any, 'getMemoryInfo').mockResolvedValue(memoryData);
218215
const config = new Configuration({ availableMemoryRatio: 1 });
219216
const snapshotter = new Snapshotter({ config, maxUsedMemoryRatio: 0.5 });
220217
// do not initialize the event intervals as we will fire them manually
@@ -245,31 +242,48 @@ describe('Snapshotter', () => {
245242
});
246243

247244
test('correctly logs critical memory overload', async () => {
248-
vitest.spyOn(utils, 'getMemoryInfoV2').mockResolvedValueOnce({ totalBytes: toBytes(10000) } as MemoryInfo);
245+
const initialMemory = toBytes(10000);
246+
const usageRatio1 = 0.75; // below warning usage
247+
const usageRatio2 = 0.76; // above warning usage
248+
const memoryData: MemoryInfo = {
249+
totalBytes: initialMemory,
250+
freeBytes: initialMemory * (1 - usageRatio1),
251+
usedBytes: initialMemory * usageRatio1,
252+
mainProcessBytes: initialMemory * usageRatio1,
253+
childProcessesBytes: 0,
254+
};
255+
256+
// Mock memory info to be able to inject custom memory measurement data.
257+
vitest.spyOn(LocalEventManager.prototype as any, 'getMemoryInfo').mockResolvedValue(memoryData);
249258
const config = new Configuration({ availableMemoryRatio: 1 });
250259
const snapshotter = new Snapshotter({ config, maxUsedMemoryRatio: 0.5 });
260+
261+
const eventManager = config.getEventManager() as LocalEventManager;
251262
await snapshotter.start();
252263
const warningSpy = vitest.spyOn(snapshotter.log, 'warning').mockImplementation(() => {});
253264

254-
// @ts-expect-error Calling private method
255-
snapshotter._memoryOverloadWarning({
256-
memCurrentBytes: toBytes(7600),
257-
});
265+
// First snapshot - below warning usage
266+
await eventManager.emitSystemInfoEvent(noop);
267+
expect(warningSpy).not.toBeCalled();
268+
269+
// Second snapshot - above warning usage
270+
memoryData.usedBytes = initialMemory * usageRatio2;
271+
memoryData.mainProcessBytes = initialMemory * usageRatio2;
272+
memoryData.freeBytes = initialMemory * (1 - usageRatio2);
273+
await eventManager.emitSystemInfoEvent(noop);
258274
expect(warningSpy).toBeCalled();
259275
warningSpy.mockReset();
260276

261-
// @ts-expect-error Calling private method
262-
snapshotter._memoryOverloadWarning({
263-
memCurrentBytes: toBytes(7500),
264-
});
277+
// Second snapshot again - repeated warning ignored
278+
await eventManager.emitSystemInfoEvent(noop);
265279
expect(warningSpy).not.toBeCalled();
280+
warningSpy.mockReset();
266281

267282
vitest.restoreAllMocks();
268283
await snapshotter.stop();
269284
});
270285

271286
test('correctly marks clientOverloaded', () => {
272-
const noop = () => {};
273287
// mock client data
274288
const apifyClient = Configuration.getStorageClient();
275289
const oldStats = apifyClient.stats;
@@ -332,4 +346,61 @@ describe('Snapshotter', () => {
332346
expect(diffBetween).toBeLessThan(SAMPLE_SIZE_MILLIS);
333347
expect(diffWithin).toBeLessThan(SAMPLE_SIZE_MILLIS);
334348
});
349+
350+
test.each([
351+
true,
352+
false,
353+
])('correctly handles dynamic vs static memory limit when total memory changes (dynamic=%s)', async (dynamic) => {
354+
/**
355+
* Two memory snapshots are emitted with the same process memory usage but different total memory.
356+
* First snapshot is overloaded in both modes. Using 60% of total memory, while the limit is 50% in both modes.
357+
* Second snapshot doubles the total memory while keeping the same usage:
358+
* - Dynamic mode (availableMemoryRatio): maxMemoryBytes should update → not overloaded
359+
* - Static mode (memoryMbytes): maxMemoryBytes stays fixed → still overloaded
360+
*/
361+
const initialTotalBytes = toBytes(100);
362+
const allowedMemoryUsageRatio = 0.5;
363+
const actualMemoryUsage = 0.6 * initialTotalBytes;
364+
365+
// Initial snapshot. Overloaded in both modes.
366+
const memoryData: MemoryInfo = {
367+
totalBytes: initialTotalBytes,
368+
freeBytes: initialTotalBytes - actualMemoryUsage,
369+
usedBytes: actualMemoryUsage,
370+
mainProcessBytes: actualMemoryUsage,
371+
childProcessesBytes: 0,
372+
};
373+
374+
// Mock memory info to be able to inject custom memory measurement data.
375+
vitest.spyOn(LocalEventManager.prototype as any, 'getMemoryInfo').mockResolvedValue(memoryData);
376+
377+
let config: Configuration;
378+
if (dynamic) {
379+
// Dynamic: Allow usage of 50 % of available memory through ratio
380+
config = new Configuration({ availableMemoryRatio: allowedMemoryUsageRatio });
381+
} else {
382+
// Static: Allow usage of 50 % of available memory through fixed value
383+
config = new Configuration({ memoryMbytes: (allowedMemoryUsageRatio * initialTotalBytes) / 1024 / 1024 });
384+
}
385+
386+
const snapshotter = new Snapshotter({ config });
387+
vitest.spyOn(LocalEventManager.prototype, 'init').mockImplementation(async () => {});
388+
const eventManager = config.getEventManager() as LocalEventManager;
389+
await snapshotter.start();
390+
391+
// First snapshot - full usage of the memory, should be overloaded in both modes
392+
await eventManager.emitSystemInfoEvent(noop);
393+
394+
// Second snapshot - total memory doubled, should be overloaded only in static mode
395+
memoryData.totalBytes = initialTotalBytes * 2;
396+
memoryData.freeBytes = memoryData.totalBytes - actualMemoryUsage;
397+
await eventManager.emitSystemInfoEvent(noop);
398+
399+
const memorySnapshots = snapshotter.getMemorySample();
400+
expect(memorySnapshots).toHaveLength(2);
401+
expect(memorySnapshots[0].isOverloaded).toBe(true);
402+
expect(memorySnapshots[1].isOverloaded).toBe(!dynamic);
403+
404+
await snapshotter.stop();
405+
});
335406
});

0 commit comments

Comments
 (0)