Repository navigation
ChargingManager reads charged counts and total as 0 after Actor.reboot(), so maxTotalChargeUsd allows a fresh budget #1209
Description
Environment
- apify (SDK for Python): 4.0.2
- Python: 3.12
- Platform: Apify platform (Docker Actor), pay-per-event pricing
- Seen on private throwaway Actors with synthetic data only
Description
After Actor.reboot(), the Actor starts again in a new process. At that second start the charging manager reports zero charged events and a zero total, even though events were already charged before the reboot. The SDK's own maxTotalChargeUsd check works from those zero values, so the Actor is allowed a fresh budget after every reboot.
My reading, not confirmed against the SDK source: the charged counts appear to come from the run's state at container start rather than from the current run state, so the restarted container sees old numbers.
Minimal reproduction
Pay-per-event Actor with one event, row-charged (invented name), priced at $0.005. Run it with a maximum cost per run of $0.05 and N = 3.
from apify import Actor
N = 3
async def main() -> None:
async with Actor:
kvs = await Actor.open_key_value_store()
cm = Actor.get_charging_manager()
# Log what the SDK believes at every start
Actor.log.info(
'SDK view at start: total=%s counts=%s',
cm.calculate_total_charged_amount(),
cm.get_charged_event_count('row-charged'),
)
if not await kvs.get_value('REBOOTED'):
for _ in range(N):
await Actor.charge('row-charged')
Actor.log.info(
'Before reboot: total=%s counts=%s',
cm.calculate_total_charged_amount(),
cm.get_charged_event_count('row-charged'),
)
await kvs.set_value('REBOOTED', True)
await Actor.reboot()
return
# Second start. Expected total=0.015 and count=3, actual is below.
Actor.log.info(
'After reboot: total=%s counts=%s max_more=%s',
cm.calculate_total_charged_amount(),
cm.get_charged_event_count('row-charged'),
cm.calculate_max_event_charge_count_within_limit('row-charged'),
)Expected vs actual
Expected at the second start: charged count 3, total $0.015, and the remaining allowance for the event computed from the $0.05 limit minus $0.015.
Actual at the second start: count 0, total 0.000, and the remaining allowance computed as if nothing had been charged. The platform's run record shows the earlier charges.
Observed runs (private Actors, synthetic data):
GYMLskZy8e5KKms6LrunRk1TWNQrPm6jJkt4p(26 Oct 2026): at the second start the SDK view was total "0.000" and change count 0, with 3 rows already charged before the reboot. The Actor's own dedupe held the 6-item total (6 x $0.005,maxTotalChargeUsd0.05).VjVVSbX9GuUC3JbGF(23 Oct 2026, user maximum $0.011): the run record ended with 1 run event and 5 trader events, $0.015, above the $0.011 maximum, withchargeLimitReachedfalse and one reboot. The SDK started from $0 spent after the reboot.KCCWkLV0mxYtopaq4(23 Oct 2026, same Actor, maximum $0.011, with an in-Actor charge state as workaround): SDK view at the second start was "0.000" / 0 again, but the run stopped at exactly $0.011 (1 run event + 3 trader events).
For the over-limit run, I cannot say whether the platform would bill the events above the maximum, only that the run record disagreed with the limit and the Actor could not detect it.
Related, not the same: #1187 (overcharge by one event when the budget is exhausted).
Impact
- Any pay-per-event Actor that reboots (or that is migrated, which I have not tested) and relies on the SDK's limit handling can exceed the user's maximum cost per run, or at least misreport spend, after the restart.
calculate_max_event_charge_count_within_limitandChargeResult.event_charge_limit_reachedare computed from the zero baseline, so an Actor following the documented pattern does not stop where it should.- Actors that dedupe their output after a restart can still be wrong on charging, because the SDK's view of what was charged is gone.
Workaround
Keep the charge state in the Actor, outside the SDK:
- Write a
CHARGE_STATErecord in the run's key-value store as "pending" before each charge and "charged" after it, so a restart can see what really happened. - Dedupe rows against what is already in the dataset before pushing and charging.
- Compute the remaining allowance yourself with
Decimal: user maximum minus the larger of your own recorded spend and the SDK total, minus what has been charged since this start; round down to whole events.
With this, runs that reboot stop at the user's maximum.
Suggested direction (for maintainers to judge)
Initialise the charging manager's counts and total from the current run state (run object or recorded charges) when it starts, instead of from start-of-container values, or document that they reset on reboot.
Activity