Skip to content

Commit fbbcb94

Browse files
committed
fix: reject a named task run that would need a part of the parent's budget
1 parent b856e79 commit fbbcb94

3 files changed

Lines changed: 77 additions & 39 deletions

File tree

‎src/apify/_actor.py‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -973,8 +973,8 @@ async def start(
973973
content_type: The content type of the input.
974974
build: Specifies the Actor build to run. It can be either a build tag or build number. By default,
975975
the run uses the build specified in the default run configuration for the Actor (typically latest).
976-
max_total_charge_usd: A limit on the total charged amount for pay-per-event Actors. When `name` is set
977-
and this Actor run was started with a `max_total_charge_usd` set by the user, the limit defaults to
976+
max_total_charge_usd: A limit on the total charged amount for pay-per-event Actors. When `run_name` is
977+
set and this Actor run was started with a `max_total_charge_usd` set by the user, the limit defaults to
978978
the part of that budget not charged by this Actor run nor reserved for its other named child runs,
979979
and a higher value is lowered to it. The limit stays reserved until the child run finishes and its
980980
charge is known.
@@ -1115,8 +1115,8 @@ async def call(
11151115
content_type: The content type of the input.
11161116
build: Specifies the Actor build to run. It can be either a build tag or build number. By default,
11171117
the run uses the build specified in the default run configuration for the Actor (typically latest).
1118-
max_total_charge_usd: A limit on the total charged amount for pay-per-event Actors. When `name` is set
1119-
and this Actor run was started with a `max_total_charge_usd` set by the user, the limit defaults to
1118+
max_total_charge_usd: A limit on the total charged amount for pay-per-event Actors. When `run_name` is
1119+
set and this Actor run was started with a `max_total_charge_usd` set by the user, the limit defaults to
11201120
the part of that budget not charged by this Actor run nor reserved for its other named child runs,
11211121
and a higher value is lowered to it. The limit stays reserved until the child run finishes and its
11221122
charge is known.
@@ -1349,7 +1349,9 @@ async def call_task(
13491349
to the recorded run. A `SUCCEEDED` run is returned as is, an `ABORTED` or `TIMED-OUT` one is
13501350
resurrected, and a new run is started only when nothing is recorded under the name, or the recorded
13511351
run `FAILED` or no longer exists. The name is bound to `task_id` exactly as passed, so reusing it with
1352-
any other value, or for an Actor, raises a `ValueError`.
1352+
any other value, or for an Actor, raises a `ValueError`. When this Actor run was started with
1353+
a `max_total_charge_usd` set by the user, a named call that would start a new run raises
1354+
a `RuntimeError`, since the task's run cannot be given a part of that budget.
13531355
abort_with_parent: If true, the child run is gracefully aborted when this Actor run is gracefully
13541356
aborted. It requires `run_name`, and the value is recorded under it, replacing the one from an earlier
13551357
call. A hard abort, a timeout or a crash of this Actor run leaves the child running.
@@ -1384,19 +1386,27 @@ async def call_task(
13841386
wait_duration=wait,
13851387
)
13861388
else:
1387-
started_run, _ = await self._find_or_start_child_run(
1388-
run_name,
1389-
task_id=task_id,
1390-
client=client,
1391-
start_run=partial(
1392-
task_client.start,
1389+
1390+
async def start_task_run(*, max_total_charge_usd: Decimal | None) -> Run:
1391+
if max_total_charge_usd is not None:
1392+
raise RuntimeError(
1393+
f'Child run "{run_name}" was not started, since a task run cannot be given a part of the '
1394+
'budget of this Actor run.'
1395+
)
1396+
return await task_client.start(
13931397
task_input=task_input,
13941398
build=build,
13951399
restart_on_error=restart_on_error,
13961400
memory_mbytes=memory_mbytes,
13971401
run_timeout=task_call_timeout,
13981402
webhooks=to_client_representations(webhooks),
1399-
),
1403+
)
1404+
1405+
started_run, _ = await self._find_or_start_child_run(
1406+
run_name,
1407+
task_id=task_id,
1408+
client=client,
1409+
start_run=start_task_run,
14001410
build=build,
14011411
max_total_charge_usd=None,
14021412
restart_on_error=restart_on_error,

‎tests/e2e/test_actor_child_runs.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,9 @@ async def main() -> None:
187187

188188
actor_id = Actor.configuration.actor_id or ''
189189
first = await Actor.start(
190-
actor_id=actor_id, run_input={'is_child': True}, name='first', max_total_charge_usd=Decimal('0.25')
190+
actor_id=actor_id, run_input={'is_child': True}, run_name='first', max_total_charge_usd=Decimal('0.25')
191191
)
192-
second = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, name='second')
192+
second = await Actor.start(actor_id=actor_id, run_input={'is_child': True}, run_name='second')
193193
try:
194194
limits = []
195195
for run in (first, second):

‎tests/unit/actor/test_actor_child_runs.py‎

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,7 +1139,7 @@ async def test_named_start_gets_the_budget_left(apify_client_async_patcher: Apif
11391139
"""A named start without a charge limit gets the part of the parent's budget it has not charged itself."""
11401140
async with Actor:
11411141
await Actor.charge('some-event', count=3)
1142-
await Actor.start('some-actor', name='child')
1142+
await Actor.start('some-actor', run_name='child')
11431143

11441144
assert started_limits(apify_client_async_patcher) == [Decimal(7)]
11451145

@@ -1159,7 +1159,7 @@ async def test_named_start_charge_limit_is_capped_at_the_budget_left(
11591159
) -> None:
11601160
"""An explicit charge limit of a named start is kept within the budget left and lowered above it."""
11611161
async with Actor:
1162-
await Actor.start('some-actor', name='child', max_total_charge_usd=requested)
1162+
await Actor.start('some-actor', run_name='child', max_total_charge_usd=requested)
11631163

11641164
assert started_limits(apify_client_async_patcher) == [expected]
11651165

@@ -1171,7 +1171,7 @@ async def test_unnamed_start_charge_limit_is_passed_through(
11711171
"""A start without a name is not tracked, so its charge limit is neither capped nor reserved."""
11721172
async with Actor:
11731173
await Actor.start('some-actor', max_total_charge_usd=Decimal(20))
1174-
await Actor.start('some-actor', name='child')
1174+
await Actor.start('some-actor', run_name='child')
11751175

11761176
assert started_limits(apify_client_async_patcher) == [Decimal(20), Decimal(10)]
11771177

@@ -1183,8 +1183,8 @@ async def test_named_start_charge_limit_is_passed_through_without_a_parent_budge
11831183
apify_client_async_patcher.patch('actor', 'start', return_value=make_run('new-run', 'READY'))
11841184

11851185
async with Actor:
1186-
await Actor.start('some-actor', name='first', max_total_charge_usd=Decimal(20))
1187-
await Actor.start('some-actor', name='second')
1186+
await Actor.start('some-actor', run_name='first', max_total_charge_usd=Decimal(20))
1187+
await Actor.start('some-actor', run_name='second')
11881188

11891189
assert started_limits(apify_client_async_patcher) == [Decimal(20), None]
11901190

@@ -1193,19 +1193,47 @@ async def test_named_start_charge_limit_is_passed_through_without_a_parent_budge
11931193
async def test_running_child_run_reserves_its_charge_limit(apify_client_async_patcher: ApifyClientAsyncPatcher) -> None:
11941194
"""The limit of a running child run is reserved, both from later child runs and from the parent's own charges."""
11951195
async with Actor:
1196-
await Actor.start('some-actor', name='first', max_total_charge_usd=Decimal(6))
1196+
await Actor.start('some-actor', run_name='first', max_total_charge_usd=Decimal(6))
11971197
charge_result = await Actor.charge('some-event', count=3)
1198-
await Actor.start('some-actor', name='second')
1198+
await Actor.start('some-actor', run_name='second')
11991199

12001200
assert charge_result.charged_count == 3
12011201
assert started_limits(apify_client_async_patcher) == [Decimal(6), Decimal(1)]
12021202

12031203

1204+
@pytest.mark.usefixtures('parent_budget')
1205+
async def test_named_call_task_is_rejected_under_a_parent_budget(
1206+
apify_client_async_patcher: ApifyClientAsyncPatcher,
1207+
) -> None:
1208+
"""A named task call that would start a run under a parent budget set by the user raises without starting it."""
1209+
apify_client_async_patcher.patch('task', 'start', return_value=make_run('task-run', 'READY'))
1210+
1211+
async with Actor:
1212+
with pytest.raises(RuntimeError, match='cannot be given a part of the budget'):
1213+
await Actor.call_task('some-task', run_name='child')
1214+
1215+
assert apify_client_async_patcher.calls['task']['start'] == []
1216+
1217+
1218+
async def test_named_call_task_starts_without_a_charge_limit(
1219+
apify_client_async_patcher: ApifyClientAsyncPatcher,
1220+
) -> None:
1221+
"""Without a parent budget, a named task call starts the task with the options the task client accepts."""
1222+
apify_client_async_patcher.patch('task', 'start', return_value=make_run('task-run', 'READY'))
1223+
apify_client_async_patcher.patch('run', 'wait_for_finish', return_value=make_run('task-run', 'SUCCEEDED'))
1224+
1225+
async with Actor:
1226+
await Actor.call_task('some-task', run_name='child')
1227+
1228+
[(_, kwargs)] = apify_client_async_patcher.calls['task']['start']
1229+
assert 'max_total_charge_usd' not in kwargs
1230+
1231+
12041232
@pytest.mark.usefixtures('parent_budget')
12051233
async def test_reserved_budget_limits_the_parent_charges() -> None:
12061234
"""The parent charges only the part of its budget not reserved for child runs."""
12071235
async with Actor:
1208-
await Actor.start('some-actor', name='child', max_total_charge_usd=Decimal(6))
1236+
await Actor.start('some-actor', run_name='child', max_total_charge_usd=Decimal(6))
12091237
charge_result = await Actor.charge('some-event', count=10)
12101238

12111239
assert charge_result.charged_count == 4
@@ -1215,17 +1243,17 @@ async def test_reserved_budget_limits_the_parent_charges() -> None:
12151243
async def test_exhausted_budget_rejects_a_named_start() -> None:
12161244
"""A named start raises when the whole parent budget is charged or reserved."""
12171245
async with Actor:
1218-
await Actor.start('some-actor', name='first')
1246+
await Actor.start('some-actor', run_name='first')
12191247
with pytest.raises(RuntimeError, match='budget of this Actor run is spent or reserved'):
1220-
await Actor.start('some-actor', name='second')
1248+
await Actor.start('some-actor', run_name='second')
12211249

12221250

12231251
@pytest.mark.usefixtures('parent_budget')
12241252
async def test_concurrent_named_starts_share_the_budget() -> None:
12251253
"""Concurrent named starts reserve their limits one at a time, so together they stay within the budget."""
12261254
async with Actor:
12271255
results = await asyncio.gather(
1228-
*(Actor.start('some-actor', name=name, max_total_charge_usd=Decimal(6)) for name in ('a', 'b')),
1256+
*(Actor.start('some-actor', run_name=name, max_total_charge_usd=Decimal(6)) for name in ('a', 'b')),
12291257
return_exceptions=True,
12301258
)
12311259
child_runs = await Actor.child_runs()
@@ -1240,11 +1268,11 @@ async def test_finished_child_run_releases_its_unused_budget(
12401268
"""A finished child run keeps only its charge reserved, and records it once it can no longer change."""
12411269
monkeypatch.setattr('apify._child_runs._STATUS_MAX_AGE', timedelta(0))
12421270
async with Actor:
1243-
first = await Actor.start('some-actor', name='first', max_total_charge_usd=Decimal(6))
1271+
first = await Actor.start('some-actor', run_name='first', max_total_charge_usd=Decimal(6))
12441272
parent_budget[first.id] = finish(parent_budget[first.id], 'SUCCEEDED', 2)
1245-
await Actor.start('some-actor', name='second', max_total_charge_usd=Decimal(3))
1273+
await Actor.start('some-actor', run_name='second', max_total_charge_usd=Decimal(3))
12461274
parent_budget['run-2'] = finish(parent_budget['run-2'], 'SUCCEEDED', 1, finished_ago=timedelta(minutes=5))
1247-
await Actor.start('some-actor', name='third')
1275+
await Actor.start('some-actor', run_name='third')
12481276
kvs = await Actor.open_key_value_store()
12491277
stored = await kvs.get_value(CHILD_RUNS_KEY)
12501278

@@ -1293,7 +1321,7 @@ async def test_resurrection_reuses_the_reservation_of_its_run(
12931321
)
12941322

12951323
async with _ActorType() as actor:
1296-
await actor.start('some-actor', name='child')
1324+
await actor.start('some-actor', run_name='child')
12971325

12981326
[(_, kwargs)] = apify_client_async_patcher.calls['run']['resurrect']
12991327
assert kwargs['max_total_charge_usd'] == Decimal(7)
@@ -1309,7 +1337,7 @@ async def test_replaced_failed_run_keeps_its_charge_reserved(
13091337
await seed_budget_record('child', 'old-run', maxTotalChargeUsd='6')
13101338

13111339
async with _ActorType() as actor:
1312-
await actor.start('some-actor', name='child')
1340+
await actor.start('some-actor', run_name='child')
13131341
kvs = await actor.open_key_value_store()
13141342
stored = await kvs.get_value(CHILD_RUNS_KEY)
13151343

@@ -1324,7 +1352,7 @@ async def test_failed_named_start_releases_its_reservation(apify_client_async_pa
13241352
async with Actor:
13251353
apify_client_async_patcher.patch('actor', 'start', replacement_method=Mock(side_effect=RuntimeError('boom')))
13261354
with pytest.raises(RuntimeError, match='boom'):
1327-
await Actor.start('some-actor', name='child')
1355+
await Actor.start('some-actor', run_name='child')
13281356
charge_result = await Actor.charge('some-event', count=10)
13291357

13301358
assert charge_result.charged_count == 10
@@ -1346,7 +1374,7 @@ async def start(*_args: Any, **_kwargs: Any) -> Run:
13461374

13471375
async with Actor:
13481376
apify_client_async_patcher.patch('actor', 'start', replacement_method=start)
1349-
start_task = asyncio.create_task(Actor.start('some-actor', name='first'))
1377+
start_task = asyncio.create_task(Actor.start('some-actor', run_name='first'))
13501378
await started.wait()
13511379
charge_result = await Actor.charge('some-event', count=1)
13521380
release.set()
@@ -1358,7 +1386,7 @@ async def start(*_args: Any, **_kwargs: Any) -> Run:
13581386
async def test_listing_child_runs_releases_the_unused_budget(parent_budget: dict[str, Run]) -> None:
13591387
"""Listing the child runs releases the unused limit of those that finished."""
13601388
async with Actor:
1361-
run = await Actor.start('some-actor', name='child', max_total_charge_usd=Decimal(6))
1389+
run = await Actor.start('some-actor', run_name='child', max_total_charge_usd=Decimal(6))
13621390
parent_budget[run.id] = finish(parent_budget[run.id], 'SUCCEEDED', 2)
13631391
await Actor.child_runs()
13641392
charge_result = await Actor.charge('some-event', count=10)
@@ -1377,7 +1405,7 @@ async def test_named_call_releases_the_unused_budget_when_the_run_finishes(
13771405
)
13781406

13791407
async with Actor:
1380-
await Actor.call('some-actor', name='child', max_total_charge_usd=Decimal(6), logger=None)
1408+
await Actor.call('some-actor', run_name='child', max_total_charge_usd=Decimal(6), logger=None)
13811409
charge_result = await Actor.charge('some-event', count=10)
13821410

13831411
assert charge_result.charged_count == 8
@@ -1393,8 +1421,8 @@ async def test_platform_default_charge_limit_is_not_shared_with_child_runs(
13931421
)
13941422

13951423
async with Actor:
1396-
await Actor.start('some-actor', name='first')
1397-
await Actor.start('some-actor', name='second', max_total_charge_usd=Decimal(20))
1424+
await Actor.start('some-actor', run_name='first')
1425+
await Actor.start('some-actor', run_name='second', max_total_charge_usd=Decimal(20))
13981426
charge_result = await Actor.charge('some-event', count=10)
13991427

14001428
assert started_limits(apify_client_async_patcher) == [None, Decimal(20)]
@@ -1430,7 +1458,7 @@ def resurrect(run_client: Any, **_: Any) -> Run:
14301458
async with _ActorType() as actor:
14311459
list_task = asyncio.create_task(actor.child_runs())
14321460
await listing.wait()
1433-
await actor.start('some-actor', name='child')
1461+
await actor.start('some-actor', run_name='child')
14341462
release.set()
14351463
await list_task
14361464
charge_result = await actor.charge('some-event', count=10)
@@ -1457,7 +1485,7 @@ async def resurrect(run_client: Any, **_: Any) -> Run:
14571485
apify_client_async_patcher.patch('run', 'resurrect', replacement_method=resurrect, is_async=True)
14581486

14591487
async with _ActorType() as actor:
1460-
start_task = asyncio.create_task(actor.start('some-actor', name='child', max_total_charge_usd=Decimal(4)))
1488+
start_task = asyncio.create_task(actor.start('some-actor', run_name='child', max_total_charge_usd=Decimal(4)))
14611489
await started.wait()
14621490
charge_result = await actor.charge('some-event', count=10)
14631491
release.set()
@@ -1479,7 +1507,7 @@ async def test_failed_resurrection_leaves_the_charge_of_its_run_to_settle(
14791507

14801508
async with _ActorType() as actor:
14811509
with pytest.raises(RuntimeError, match='boom'):
1482-
await actor.start('some-actor', name='child')
1510+
await actor.start('some-actor', run_name='child')
14831511
monkeypatch.setattr('apify._child_runs._CHARGE_SETTLE_TIME', timedelta(0))
14841512
await actor.child_runs()
14851513
kvs = await actor.open_key_value_store()

0 commit comments

Comments
 (0)