11from __future__ import annotations
22
3- import asyncio
43import math
54from contextvars import ContextVar
65from dataclasses import dataclass
1817 PricePerDatasetItemActorPricingInfo ,
1918 PricingModel ,
2019)
21- from apify ._utils import docs_group , ensure_context
20+ from apify ._utils import ReentrantLock , docs_group , ensure_context
2221from apify .log import logger
2322from apify .storages import Dataset
2423
@@ -53,7 +52,7 @@ class ChargingManager(Protocol):
5352 - Apify platform documentation: https://docs.apify.com/platform/actors/publishing/monetize
5453 """
5554
56- charge_lock : asyncio . Lock
55+ charge_lock : ReentrantLock
5756 """Lock to synchronize charge operations. Prevents race conditions between `charge` and `push_data` calls."""
5857
5958 async def charge (self , event_name : str , count : int = 1 ) -> ChargeResult :
@@ -114,6 +113,12 @@ def compute_push_data_limit(
114113 Max number of items that can be pushed within the budget.
115114 """
116115
116+ def is_event_charge_limit_reached (self , event_name : str ) -> bool :
117+ """Return True if the remaining budget is insufficient to charge even a single event of the given type."""
118+
119+ def compute_chargeable (self ) -> dict [str , int | None ]:
120+ """Compute the maximum number of events of each type that can be charged within the current budget."""
121+
117122
118123@docs_group ('Charging' )
119124@dataclass (frozen = True )
@@ -170,7 +175,7 @@ def __init__(self, configuration: Configuration, client: ApifyClientAsync) -> No
170175 self ._not_ppe_warning_printed = False
171176 self .active = False
172177
173- self .charge_lock = asyncio . Lock ()
178+ self .charge_lock = ReentrantLock ()
174179
175180 async def __aenter__ (self ) -> None :
176181 """Initialize the charging manager - this is called by the `Actor` class and shouldn't be invoked manually."""
@@ -244,13 +249,6 @@ async def __aexit__(
244249
245250 @_ensure_context
246251 async def charge (self , event_name : str , count : int = 1 ) -> ChargeResult :
247- def calculate_chargeable () -> dict [str , int | None ]:
248- """Calculate the maximum number of events of each type that can be charged within the current budget."""
249- return {
250- event_name : self .calculate_max_event_charge_count_within_limit (event_name )
251- for event_name in self ._pricing_info
252- }
253-
254252 # For runs that do not use the pay-per-event pricing model, just print a warning and return
255253 if self ._pricing_model != 'PAY_PER_EVENT' :
256254 if not self ._not_ppe_warning_printed :
@@ -262,79 +260,81 @@ def calculate_chargeable() -> dict[str, int | None]:
262260 return ChargeResult (
263261 event_charge_limit_reached = False ,
264262 charged_count = 0 ,
265- chargeable_within_limit = calculate_chargeable (),
263+ chargeable_within_limit = self . compute_chargeable (),
266264 )
267265
268- # START OF CRITICAL SECTION - no awaits here
269-
270- # Determine the maximum amount of events that can be charged within the budget
271- max_chargeable = self .calculate_max_event_charge_count_within_limit (event_name )
272- charged_count = min (count , max_chargeable if max_chargeable is not None else count )
273-
274- if charged_count == 0 :
266+ if count <= 0 :
275267 return ChargeResult (
276- event_charge_limit_reached = True ,
268+ event_charge_limit_reached = self . is_event_charge_limit_reached ( event_name ) ,
277269 charged_count = 0 ,
278- chargeable_within_limit = calculate_chargeable (),
270+ chargeable_within_limit = self . compute_chargeable (),
279271 )
280272
281- pricing_info = self ._pricing_info .get (
282- event_name ,
283- PricingInfoItem (
284- # Use a nonzero price for local development so that the maximum budget can be reached.
285- price = Decimal () if self ._is_at_home else Decimal (1 ),
286- title = f"Unknown event '{ event_name } '" ,
287- ),
288- )
273+ async with self .charge_lock ():
274+ # Determine the maximum amount of events that can be charged within the budget
275+ max_chargeable = self .calculate_max_event_charge_count_within_limit (event_name )
276+ charged_count = min (count , max_chargeable if max_chargeable is not None else count )
289277
290- # Update the charging state
291- self . _charging_state . setdefault ( event_name , ChargingStateItem ( 0 , Decimal ()))
292- self . _charging_state [ event_name ]. charge_count += charged_count
293- self . _charging_state [ event_name ]. total_charged_amount += charged_count * pricing_info . price
294-
295- # END OF CRITICAL SECTION
278+ if charged_count == 0 :
279+ return ChargeResult (
280+ event_charge_limit_reached = True ,
281+ charged_count = 0 ,
282+ chargeable_within_limit = self . compute_chargeable (),
283+ )
296284
297- # If running on the platform, call the charge endpoint
298- if self ._is_at_home :
299- if self ._actor_run_id is None :
300- raise RuntimeError ('Actor run ID not configured' )
301-
302- if event_name .startswith ('apify-' ):
303- # Synthetic events (e.g. apify-default-dataset-item) are tracked internally only,
304- # the platform handles them automatically based on dataset writes.
305- pass
306- elif event_name in self ._pricing_info :
307- await self ._client .run (self ._actor_run_id ).charge (event_name , charged_count )
308- else :
309- logger .warning (f"Attempting to charge for an unknown event '{ event_name } '" )
310-
311- # Log the charged operation (if enabled)
312- if self ._charging_log_dataset :
313- await self ._charging_log_dataset .push_data (
314- {
315- 'event_name' : event_name ,
316- 'event_title' : pricing_info .title ,
317- 'event_price_usd' : round (pricing_info .price , 3 ),
318- 'charged_count' : charged_count ,
319- 'timestamp' : datetime .now (timezone .utc ).isoformat (),
320- }
285+ pricing_info = self ._pricing_info .get (
286+ event_name ,
287+ PricingInfoItem (
288+ # Use a nonzero price for local development so that the maximum budget can be reached.
289+ price = Decimal () if self ._is_at_home else Decimal (1 ),
290+ title = f"Unknown event '{ event_name } '" ,
291+ ),
321292 )
322293
323- # If it is not possible to charge the full amount, log that fact
324- if charged_count < count :
325- subject = 'instance' if count == 1 else 'instances'
326- logger .info (
327- f"Charging { count } { subject } of '{ event_name } ' event would exceed max_total_charge_usd "
328- f'- only { charged_count } events were charged'
329- )
294+ # Update the charging state
295+ self ._charging_state .setdefault (event_name , ChargingStateItem (0 , Decimal ()))
296+ self ._charging_state [event_name ].charge_count += charged_count
297+ self ._charging_state [event_name ].total_charged_amount += charged_count * pricing_info .price
298+
299+ # If running on the platform, call the charge endpoint
300+ if self ._is_at_home :
301+ if self ._actor_run_id is None :
302+ raise RuntimeError ('Actor run ID not configured' )
303+
304+ if event_name .startswith ('apify-' ):
305+ # Synthetic events (e.g. apify-default-dataset-item) are tracked internally only,
306+ # the platform handles them automatically based on dataset writes.
307+ pass
308+ elif event_name in self ._pricing_info :
309+ await self ._client .run (self ._actor_run_id ).charge (event_name , charged_count )
310+ else :
311+ logger .warning (f"Attempting to charge for an unknown event '{ event_name } '" )
312+
313+ # Log the charged operation (if enabled)
314+ if self ._charging_log_dataset :
315+ await self ._charging_log_dataset .push_data (
316+ {
317+ 'event_name' : event_name ,
318+ 'event_title' : pricing_info .title ,
319+ 'event_price_usd' : round (pricing_info .price , 3 ),
320+ 'charged_count' : charged_count ,
321+ 'timestamp' : datetime .now (timezone .utc ).isoformat (),
322+ }
323+ )
330324
331- max_charge_count = self .calculate_max_event_charge_count_within_limit (event_name )
325+ # If it is not possible to charge the full amount, log that fact
326+ if charged_count < count :
327+ subject = 'instance' if count == 1 else 'instances'
328+ logger .info (
329+ f"Charging { count } { subject } of '{ event_name } ' event would exceed max_total_charge_usd "
330+ f'- only { charged_count } events were charged'
331+ )
332332
333- return ChargeResult (
334- event_charge_limit_reached = max_charge_count is not None and max_charge_count <= 0 ,
335- charged_count = charged_count ,
336- chargeable_within_limit = calculate_chargeable (),
337- )
333+ return ChargeResult (
334+ event_charge_limit_reached = self . is_event_charge_limit_reached ( event_name ) ,
335+ charged_count = charged_count ,
336+ chargeable_within_limit = self . compute_chargeable (),
337+ )
338338
339339 @_ensure_context
340340 def calculate_total_charged_amount (self ) -> Decimal :
@@ -394,6 +394,18 @@ def compute_push_data_limit(
394394 max_count = max (0 , math .floor (result )) if result .is_finite () else items_count
395395 return min (items_count , max_count )
396396
397+ @_ensure_context
398+ def is_event_charge_limit_reached (self , event_name : str ) -> bool :
399+ max_charge_count = self .calculate_max_event_charge_count_within_limit (event_name )
400+ return max_charge_count is not None and max_charge_count <= 0
401+
402+ @_ensure_context
403+ def compute_chargeable (self ) -> dict [str , int | None ]:
404+ return {
405+ event_name : self .calculate_max_event_charge_count_within_limit (event_name )
406+ for event_name in self ._pricing_info
407+ }
408+
397409 async def _fetch_pricing_info (self ) -> _FetchedPricingInfoDict :
398410 """Fetch pricing information from environment variables or API."""
399411 # Check if pricing info is available via environment variables
0 commit comments