Skip to content

Commit 78eeee7

Browse files
authored
feat: add calculate-memory command to actor (#980)
This PR adds new command to cli to allow developers to test their `defaultMemoryMbytes` expressions. Those expressions are used to dynamically calculate memory of the run based on the `input` and `runOptions`. E.g. of usage ```shell apify actor calculate-memory --input ./input.json --maxTotalChargeUsd=25 --timeoutSecs=360 ``` Full specification in Notion 👇 https://www.notion.so/apify/Dynamic-default-Actor-memory-WIP-293f39950a2280b9aa34fa9cd07f52a4?source=copy_link
1 parent ef27d2e commit 78eeee7

File tree

8 files changed

+380
-18
lines changed

8 files changed

+380
-18
lines changed

docs/reference.md

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,13 @@ These commands help you develop Actors locally. Use them to create new Actor pro
194194
195195
```sh
196196
DESCRIPTION
197-
Creates an Actor project from a template in a new directory.
197+
Creates an Actor project from a template in a new directory. The command
198+
automatically initializes a git repository in the newly created Actor
199+
directory.
198200

199201
USAGE
200202
$ apify create [actorName] [--omit-optional-deps]
201-
[--skip-dependency-install] [-t <value>]
203+
[--skip-dependency-install] [--skip-git-init] [-t <value>]
202204

203205
ARGUMENTS
204206
actorName Name of the Actor and its directory
@@ -208,6 +210,8 @@ FLAGS
208210
dependencies.
209211
--skip-dependency-install Skip installing Actor
210212
dependencies.
213+
--skip-git-init Skip initializing a git
214+
repository in the Actor directory.
211215
-t, --template=<value> Template for the
212216
Actor. If not provided, the command will prompt for
213217
it. Visit
@@ -373,18 +377,54 @@ DESCRIPTION
373377
Manages runtime data operations inside of a running Actor.
374378
375379
SUBCOMMANDS
376-
actor set-value Sets or removes record into the
377-
default key-value store associated with the Actor run.
378-
actor push-data Saves data to Actor's run default
379-
dataset.
380-
actor get-value Gets a value from the default
381-
key-value store associated with the Actor run.
382-
actor get-public-url Get an HTTP URL that allows public
383-
access to a key-value store item.
384-
actor get-input Gets the Actor input value from the
385-
default key-value store associated with the Actor run.
386-
actor charge Charge for a specific event in the
387-
pay-per-event Actor run.
380+
actor set-value Sets or removes record into the
381+
default key-value store associated with the Actor run.
382+
actor push-data Saves data to Actor's run
383+
default dataset.
384+
actor get-value Gets a value from the default
385+
key-value store associated with the Actor run.
386+
actor get-public-url Get an HTTP URL that allows
387+
public access to a key-value store item.
388+
actor get-input Gets the Actor input value from
389+
the default key-value store associated with the Actor
390+
run.
391+
actor charge Charge for a specific event in
392+
the pay-per-event Actor run.
393+
actor calculate-memory Calculates the Actor’s dynamic
394+
memory usage based on a memory expression from
395+
actor.json, input data, and run options.
396+
```
397+
398+
##### `apify actor calculate-memory`
399+
400+
```sh
401+
DESCRIPTION
402+
Calculates the Actor’s dynamic memory usage based on a memory expression from
403+
actor.json, input data, and run options.
404+
405+
USAGE
406+
$ apify actor calculate-memory [--build <value>]
407+
[--default-memory-mbytes <value>]
408+
[--input <value>] [--max-items <value>]
409+
[--max-total-charge-usd <value>]
410+
[--timeout-secs <value>]
411+
412+
FLAGS
413+
--build=<value> Actor build
414+
version or build tag to evaluate the
415+
expression with.
416+
--default-memory-mbytes=<value>
417+
Memory-calculation expression (in MB). If
418+
omitted, the value is loaded from the
419+
actor.json file.
420+
--input=<value> Path to the
421+
input JSON file used for the calculation.
422+
--max-items=<value> Maximum
423+
number of items Actor can output.
424+
--max-total-charge-usd=<value> Maximum
425+
total charge in USD.
426+
--timeout-secs=<value> Maximum run
427+
timeout, in seconds.
388428
```
389429
390430
##### `apify actor charge`
@@ -517,7 +557,7 @@ DESCRIPTION
517557
518558
USAGE
519559
$ apify actors push [actorId] [-b <value>] [--dir <value>]
520-
[--force] [--open] [-v <value>] [-w <value>]
560+
[-f] [--open] [-v <value>] [-w <value>]
521561
522562
ARGUMENTS
523563
actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or
@@ -530,9 +570,9 @@ FLAGS
530570
it is taken from the '.actor/actor.json' file
531571
--dir=<value> Directory where the
532572
Actor is located
533-
--force Push an Actor even when
534-
the local files are older than the Actor on the
535-
platform.
573+
-f, --force Push an Actor even
574+
when the local files are older than the Actor on
575+
the platform.
536576
--open Whether to open the
537577
browser automatically to the Actor details page.
538578
-v, --version=<value> Actor version number

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"node": ">=20"
6464
},
6565
"dependencies": {
66+
"@apify/actor-memory-expression": "^0.1.3",
6667
"@apify/actor-templates": "^0.1.5",
6768
"@apify/consts": "^2.36.0",
6869
"@apify/input_schema": "^3.17.0",

scripts/generate-cli-docs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const categories: Record<string, CommandsInCategory[]> = {
2929
{ command: Commands.actorsRm },
3030

3131
{ command: Commands.actor },
32+
{ command: Commands.actorCalculateMemory },
3233
{ command: Commands.actorCharge },
3334
{ command: Commands.actorGetInput },
3435
{ command: Commands.actorGetPublicUrl },

src/commands/_register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BuiltApifyCommand } from '../lib/command-framework/apify-command.js';
22
import { ActorIndexCommand } from './actor/_index.js';
3+
import { ActorCalculateMemoryCommand } from './actor/calculate-memory.js';
34
import { ActorChargeCommand } from './actor/charge.js';
45
import { ActorGetInputCommand } from './actor/get-input.js';
56
import { ActorGetPublicUrlCommand } from './actor/get-public-url.js';
@@ -73,6 +74,7 @@ export const actorCommands = [
7374
ActorGetPublicUrlCommand,
7475
ActorGetInputCommand,
7576
ActorChargeCommand,
77+
ActorCalculateMemoryCommand,
7678

7779
// top-level
7880
HelpCommand,

src/commands/actor/_index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
2+
import { ActorCalculateMemoryCommand } from './calculate-memory.js';
23
import { ActorChargeCommand } from './charge.js';
34
import { ActorGetInputCommand } from './get-input.js';
45
import { ActorGetPublicUrlCommand } from './get-public-url.js';
@@ -19,6 +20,7 @@ export class ActorIndexCommand extends ApifyCommand<typeof ActorIndexCommand> {
1920
ActorGetPublicUrlCommand,
2021
ActorGetInputCommand,
2122
ActorChargeCommand,
23+
ActorCalculateMemoryCommand,
2224
];
2325

2426
async run() {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { join, resolve } from 'node:path';
2+
import process from 'node:process';
3+
4+
import { calculateRunDynamicMemory } from '@apify/actor-memory-expression';
5+
6+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
7+
import { Flags } from '../../lib/command-framework/flags.js';
8+
import { CommandExitCodes } from '../../lib/consts.js';
9+
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
10+
import { error, info, success } from '../../lib/outputs.js';
11+
import { getJsonFileContent, getLocalKeyValueStorePath } from '../../lib/utils.js';
12+
13+
const DEFAULT_INPUT_PATH = join(getLocalKeyValueStorePath('default'), 'INPUT.json');
14+
15+
/**
16+
* This command can be used to test dynamic memory calculation expressions
17+
* defined in actor.json or provided via command-line flag.
18+
*
19+
* Dynamic memory allows Actors to adjust their memory usage based on input data
20+
* and run options, optimizing resource allocation and costs.
21+
*/
22+
export class ActorCalculateMemoryCommand extends ApifyCommand<typeof ActorCalculateMemoryCommand> {
23+
static override name = 'calculate-memory' as const;
24+
25+
static override description =
26+
`Calculates the Actor’s dynamic memory usage based on a memory expression from actor.json, input data, and run options.`;
27+
28+
/**
29+
* Additional run options exist (e.g., memoryMbytes, disksMbytes, etc.),
30+
* but we intentionally omit them here. These options are rarely needed and
31+
* exposing them would introduce unnecessary confusion for users.
32+
*/
33+
static override flags = {
34+
input: Flags.string({
35+
description: 'Path to the input JSON file used for the calculation.',
36+
required: false,
37+
default: DEFAULT_INPUT_PATH,
38+
}),
39+
defaultMemoryMbytes: Flags.string({
40+
description:
41+
'Memory-calculation expression (in MB). If omitted, the value is loaded from the actor.json file.',
42+
required: false,
43+
}),
44+
build: Flags.string({
45+
description: 'Actor build version or build tag to evaluate the expression with.',
46+
required: false,
47+
}),
48+
timeoutSecs: Flags.integer({
49+
description: 'Maximum run timeout, in seconds.',
50+
required: false,
51+
}),
52+
maxItems: Flags.integer({
53+
description: 'Maximum number of items Actor can output.',
54+
required: false,
55+
}),
56+
maxTotalChargeUsd: Flags.integer({
57+
description: 'Maximum total charge in USD.',
58+
required: false,
59+
}),
60+
};
61+
62+
async run() {
63+
const { input, defaultMemoryMbytes, ...runOptions } = this.flags;
64+
65+
let memoryExpression: string | undefined = defaultMemoryMbytes;
66+
67+
// If not provided via flag, try to load from actor.json
68+
if (!memoryExpression) {
69+
memoryExpression = await this.getExpressionFromConfig();
70+
}
71+
72+
if (!memoryExpression) {
73+
throw new Error(
74+
`No memory-calculation expression found. Provide it via the --defaultMemoryMbytes flag or define defaultMemoryMbytes in actor.json.`,
75+
);
76+
}
77+
78+
const inputPath = resolve(process.cwd(), this.flags.input);
79+
const inputJson = getJsonFileContent(inputPath) ?? {};
80+
81+
info({ message: `Evaluating memory expression: ${memoryExpression}` });
82+
83+
try {
84+
const result = await calculateRunDynamicMemory(memoryExpression, {
85+
input: inputJson,
86+
runOptions,
87+
});
88+
success({ message: `Calculated memory: ${result} MB`, stdout: true });
89+
} catch (err) {
90+
error({ message: `Memory calculation failed: ${(err as Error).message}` });
91+
}
92+
}
93+
94+
/**
95+
* Helper to load the `defaultMemoryMbytes` expression from actor.json.
96+
*/
97+
private async getExpressionFromConfig(): Promise<string | undefined> {
98+
const cwd = process.cwd();
99+
const localConfigResult = await useActorConfig({ cwd });
100+
101+
if (localConfigResult.isErr()) {
102+
const { message, cause } = localConfigResult.unwrapErr();
103+
104+
error({ message: `${message}${cause ? `\n ${cause.message}` : ''}` });
105+
process.exitCode = CommandExitCodes.InvalidActorJson;
106+
return;
107+
}
108+
109+
const { config: localConfig } = localConfigResult.unwrap();
110+
return localConfig?.defaultMemoryMbytes?.toString();
111+
}
112+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mkdirSync, writeFileSync } from 'node:fs';
2+
import { mkdir } from 'node:fs/promises';
3+
import { dirname } from 'node:path';
4+
5+
import { ActorCalculateMemoryCommand } from '../../../../src/commands/actor/calculate-memory.js';
6+
import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js';
7+
import { EMPTY_LOCAL_CONFIG, LOCAL_CONFIG_PATH } from '../../../../src/lib/consts.js';
8+
import { getLocalKeyValueStorePath } from '../../../../src/lib/utils.js';
9+
import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js';
10+
import { useTempPath } from '../../../__setup__/hooks/useTempPath.js';
11+
import { resetCwdCaches } from '../../../__setup__/reset-cwd-caches.js';
12+
13+
const { beforeAllCalls, afterAllCalls, joinPath } = useTempPath('calculate-memory', {
14+
create: true,
15+
remove: true,
16+
cwd: true,
17+
cwdParent: false,
18+
});
19+
20+
const { lastErrorMessage, lastLogMessage } = useConsoleSpy();
21+
22+
const createActorJson = async (overrides: Record<string, unknown> = {}) => {
23+
const actorJson = { ...EMPTY_LOCAL_CONFIG, ...overrides };
24+
25+
await mkdir(joinPath('.actor'), { recursive: true });
26+
writeFileSync(joinPath(LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' });
27+
};
28+
29+
describe('apify actor calculate-memory', () => {
30+
const START_URLS_LENGTH_BASED_MEMORY_EXPRESSION = "get(input, 'startUrls.length', 1) * 1024";
31+
const DEFAULT_INPUT = { startUrls: [1, 2, 3, 4] };
32+
33+
const inputPath = joinPath(getLocalKeyValueStorePath('default'), 'INPUT.json');
34+
35+
beforeAll(async () => {
36+
mkdirSync(dirname(inputPath), { recursive: true });
37+
writeFileSync(inputPath, JSON.stringify(DEFAULT_INPUT), { flag: 'w' });
38+
await beforeAllCalls();
39+
});
40+
41+
afterAll(async () => {
42+
await afterAllCalls();
43+
});
44+
45+
beforeEach(() => {
46+
resetCwdCaches();
47+
});
48+
49+
it('should fail when default memory is not provided in flags or actor.json', async () => {
50+
await createActorJson();
51+
52+
await testRunCommand(ActorCalculateMemoryCommand, {});
53+
54+
expect(lastErrorMessage()).toMatch(/No memory-calculation expression found./);
55+
});
56+
57+
it('should calculate memory using defaultMemoryMbytes flag', async () => {
58+
await testRunCommand(ActorCalculateMemoryCommand, {
59+
flags_input: inputPath,
60+
flags_defaultMemoryMbytes: START_URLS_LENGTH_BASED_MEMORY_EXPRESSION,
61+
});
62+
63+
expect(lastLogMessage()).toMatch(/4096 MB/);
64+
});
65+
66+
it('should calculate memory using expression from actor.json', async () => {
67+
await createActorJson({ defaultMemoryMbytes: START_URLS_LENGTH_BASED_MEMORY_EXPRESSION });
68+
69+
await testRunCommand(ActorCalculateMemoryCommand, {
70+
flags_input: inputPath,
71+
});
72+
73+
expect(lastLogMessage()).toMatch(/4096 MB/);
74+
});
75+
76+
it('should fallback to default input path if input flag is not provided', async () => {
77+
await createActorJson({ defaultMemoryMbytes: START_URLS_LENGTH_BASED_MEMORY_EXPRESSION });
78+
79+
await testRunCommand(ActorCalculateMemoryCommand, {});
80+
81+
expect(lastLogMessage()).toMatch(/4096 MB/);
82+
});
83+
84+
it('should report error if memory calculation expression is invalid', async () => {
85+
await createActorJson({ defaultMemoryMbytes: 'invalid expression' });
86+
87+
await testRunCommand(ActorCalculateMemoryCommand, {
88+
flags_defaultMemoryMbytes: 'invalid expression',
89+
});
90+
91+
expect(lastErrorMessage()).toMatch(/Memory calculation failed: /);
92+
});
93+
});

0 commit comments

Comments
 (0)