Files
2026-07-13 13:23:39 +08:00

287 lines
11 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import os from 'node:os';
import { getCgroupsVersion } from '@crawlee/utils';
import {
getContainerCpuUsage,
getCpuPeriod,
getCpuQuota,
getCurrentCpuTicks,
getCurrentCpuTicksV2,
getSystemCpuUsage,
sampleCpuUsage,
} from '../../packages/utils/src/internals/systemInfoV2/cpu-info';
vitest.mock('@crawlee/utils/src/internals/general', async (importActual) => {
const original: typeof import('@crawlee/utils') = await importActual();
return {
...original,
getCgroupsVersion: vitest.fn(),
};
});
vitest.mock('node:fs/promises', async (importActual) => {
const originalFs: typeof import('node:fs/promises') = await importActual();
return {
...originalFs,
readFile: vitest.fn(originalFs.readFile),
};
});
const getCgroupsVersionSpy = vitest.mocked(getCgroupsVersion);
const readFileSpy = vitest.mocked(readFile);
describe('getCurrentCpuTicks()', () => {
test('calculates cpu load based on os.cpus', () => {
// For two CPUs, we simulate:
// CPU 1: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } → total = 200, idle = 50
// CPU 2: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } → total = 400, idle = 100
// Combined: total = 600, idle = 150 → load = 1 - (150/600) = 0.75
const cpusMock = vitest
.spyOn(os, 'cpus')
.mockReturnValue([
{ times: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } },
{ times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } },
] as os.CpuInfo[]);
const load = getCurrentCpuTicks();
expect(load).toBeCloseTo(0.75);
cpusMock.mockRestore();
});
});
describe('getCpuQuota()', () => {
test('V1: returns null for unlimited quota (-1)', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') {
return Promise.resolve('-1\n');
}
throw new Error(`Unexpected path ${path}`);
});
const quota = await getCpuQuota('V1');
expect(quota).toBeNull();
});
test('V1: returns numeric quota', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') {
return Promise.resolve('200000\n');
}
throw new Error(`Unexpected path ${path}`);
});
const quota = await getCpuQuota('V1');
expect(quota).toEqual(200000);
});
test('V2: returns null for unlimited quota ("max")', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu.max') {
return Promise.resolve('max 100000');
}
throw new Error(`Unexpected path ${path}`);
});
const quota = await getCpuQuota('V2');
expect(quota).toBeNull();
});
test('V2: returns numeric quota', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu.max') {
return Promise.resolve('200000 100000');
}
throw new Error(`Unexpected path ${path}`);
});
const quota = await getCpuQuota('V2');
expect(quota).toBe(200000);
});
});
describe('getCpuPeriod()', () => {
test('V1: returns period', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') {
return Promise.resolve('100000\n');
}
throw new Error(`Unexpected path ${path}`);
});
const period = await getCpuPeriod('V1');
expect(period).toBe(100000);
});
test('V2: returns period from the second field', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu.max') {
return Promise.resolve('200000 100000');
}
throw new Error(`Unexpected path ${path}`);
});
const period = await getCpuPeriod('V2');
expect(period).toBe(100000);
});
});
describe('getContainerCpuUsage()', () => {
test('V1: returns container cpu usage', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpuacct/cpuacct.usage') {
return Promise.resolve('123456789\n');
}
throw new Error(`Unexpected path ${path}`);
});
const usage = await getContainerCpuUsage('V1');
expect(usage).toBe(123456789);
});
test('V2: parses usage_usec and converts to nanoseconds', async () => {
// Simulate a file with a line containing "usage_usec"
const fileContent = 'other 0\nusage_usec 5000\nmoreinfo 0';
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu.stat') {
return Promise.resolve(fileContent);
}
throw new Error(`Unexpected path ${path}`);
});
const usage = await getContainerCpuUsage('V2');
expect(usage).toBe(5000 * 1000);
});
});
describe('getSystemCpuUsage()', () => {
test('parses /proc/stat correctly', async () => {
// Provide a fake /proc/stat file with a proper "cpu" line.
// Example: "cpu 100 0 50 150 0 0 0" → total ticks = 300,
// so systemUsage = (300 * 1e9) / 100 = 3000000000.
const statContent = 'cpu 100 0 50 150 0 0 0\notherline';
readFileSpy.mockImplementation(async (path) => {
if (path === '/proc/stat') {
return Promise.resolve(statContent);
}
throw new Error(`Unexpected path ${path}`);
});
const usage = await getSystemCpuUsage();
expect(usage).toBeCloseTo(3000000000);
});
test('throws error if no cpu line is found', async () => {
readFileSpy.mockImplementation(async (path) => {
if (path === '/proc/stat') {
return Promise.resolve('no cpu info');
}
throw new Error(`Unexpected path ${path}`);
});
await expect(getSystemCpuUsage()).rejects.toThrow('no cpu line');
});
});
describe('sampleCpuUsage()', () => {
test('returns a valid CpuSample for V1', async () => {
// For V1, getContainerCpuUsage reads from its stat file and
// getSystemCpuUsage reads from /proc/stat.
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpuacct/cpuacct.usage') {
return Promise.resolve('1000000\n');
}
if (path === '/proc/stat') {
return Promise.resolve('cpu 100 0 50 150 0 0 0\n');
}
throw new Error(`Unexpected path ${path}`);
});
const sample = await sampleCpuUsage('V1');
expect(sample).toEqual({
containerUsage: 1000000,
systemUsage: 3000000000, // as computed above.
});
});
test('returns a valid CpuSample for V2', async () => {
// For V1, getContainerCpuUsage reads from its stat file and
// getSystemCpuUsage reads from /proc/stat.
readFileSpy.mockImplementation(async (path) => {
if (path === '/sys/fs/cgroup/cpu.stat') {
return Promise.resolve('other 0\nusage_usec 1000\nmoreinfo 0');
}
if (path === '/proc/stat') {
return Promise.resolve('cpu 100 0 50 150 0 0 0\n');
}
throw new Error(`Unexpected path ${path}`);
});
const sample = await sampleCpuUsage('V2');
expect(sample).toEqual({
containerUsage: 1000000,
systemUsage: 3000000000, // as computed above.
});
});
});
describe('getCpuInfo()', () => {
test('returns bare metal cpu ticks in AWS Lambda environment', async () => {
// Simulate AWS Lambda by setting the env variable.
process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = '128';
const cpusMock = vitest
.spyOn(os, 'cpus')
.mockReturnValue([{ times: { user: 100, nice: 0, sys: 50, idle: 50, irq: 0 } }] as os.CpuInfo[]);
const load = await getCurrentCpuTicksV2();
// For one CPU: total = 100+0+50+50 = 200, idle = 50 → load = 0.75.
expect(load).toBeCloseTo(0.75);
cpusMock.mockRestore();
delete process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE;
});
test('returns bare metal cpu ticks when not containerized', async () => {
const cpusMock = vitest
.spyOn(os, 'cpus')
.mockReturnValue([{ times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } }] as os.CpuInfo[]);
const load = await getCurrentCpuTicksV2();
// For one CPU: total = 200+0+100+100 = 400, idle = 100 → load = 0.75.
expect(load).toBeCloseTo(0.75);
cpusMock.mockRestore();
});
test('returns container-aware cpu usage when containerized with quota set', async () => {
getCgroupsVersionSpy.mockResolvedValueOnce('V1');
// For V1:
// - getCpuQuota: return 200000 → quota = 200000.
// - getCpuPeriod: return 100000 → period = 100000.
// cpuAllowance = quota/period = 2.
// - sampleCpuUsage: returns container usage and system usage.
// For container usage, simulate "1000000000\n"
// For system usage, simulate a /proc/stat line with 300 total ticks,
// so systemUsage = (300 * 1e9) / 100 = 3000000000.
readFileSpy
.mockResolvedValueOnce('200000\n') // for getCpuQuota
.mockResolvedValueOnce('100000\n') // for getCpuPeriod
.mockResolvedValueOnce('1000000000\n') // for getContainerCpuUsage
.mockResolvedValueOnce('cpu 300 0 0 0 0 0 0\n'); // for getSystemCpuUsage
// Simulate a system with 2 CPUs.
const cpusMock = vitest
.spyOn(os, 'cpus')
.mockReturnValue([
{ times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } },
{ times: { user: 200, nice: 0, sys: 100, idle: 100, irq: 0 } },
] as os.CpuInfo[]);
// Initially, previousSample is { containerUsage: 0, systemUsage: 0 }.
const result = await getCurrentCpuTicksV2(true);
// Calculation:
// containerDelta = 1000000, systemDelta = 3000000000, numCpus = 2, cpuAllowance = 2.
// So: ((1000000000 / 3000000000) * 2) / 2 ≈ 0.3333
expect(result).toBeCloseTo(0.3333, 4);
cpusMock.mockRestore();
});
test('returns bare metal cpu ticks when containerized but no cgroup quota', async () => {
getCgroupsVersionSpy.mockResolvedValueOnce('V1');
// For V1, a quota of "-1" signals no limit → quota becomes null.
readFileSpy.mockResolvedValueOnce('-1\n'); // getCpuQuota returns null
// In this branch, getCpuInfo falls back to getCurrentCpuTicks.
const cpusMock = vitest
.spyOn(os, 'cpus')
.mockReturnValue([{ times: { user: 300, nice: 0, sys: 150, idle: 150, irq: 0 } }] as os.CpuInfo[]);
const result = await getCurrentCpuTicksV2(true);
// For one CPU: total = 300+0+150+150 = 600, idle = 150 → load = 0.75.
expect(result).toBeCloseTo(0.75);
cpusMock.mockRestore();
});
});