Skip to content

ChargingManager reads charged counts and total as 0 after Actor.reboot(), so maxTotalChargeUsd allows a fresh budget #1209

Open

Description

@yappmaniac

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):

  • GYMLskZy8e5KKms6L run Rk1TWNQrPm6jJkt4p (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, maxTotalChargeUsd 0.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, with chargeLimitReached false 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_limit and ChargeResult.event_charge_limit_reached are 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:

  1. Write a CHARGE_STATE record in the run's key-value store as "pending" before each charge and "charged" after it, so a restart can see what really happened.
  2. Dedupe rows against what is already in the dataset before pushing and charging.
  3. 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

  1. added
    t-toolingIssues with this label are in the ownership of the tooling team.
    on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    t-toolingIssues with this label are in the ownership of the tooling team.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions