Skip to content

feat: add a concurrency limit for named child runs - #1154

Open
vdusek wants to merge 16 commits into
masterfrom
feat/child-run-concurrency-limit
Open

vdusek wants to merge 16 commits into
masterfrom
feat/child-run-concurrency-limit

Conversation

@vdusek

@vdusek vdusek commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds Actor.set_named_child_run_limits(max_concurrent_runs=...). While that many named child runs are READY, RUNNING, ABORTING or TIMING-OUT, a named Actor.start, Actor.call, Actor.start_task or Actor.call_task that would start or resurrect a run waits for one of them to finish. Reattaching to a recorded run never waits, and runs without a run_name aren't counted.

The count comes from the child run registry, so children started before a migration count too. A child's status is fetched again before counting once it's more than 10 seconds old, so a finished run nobody waited on stops counting. Actor.call and Actor.call_task stop counting their run as soon as it finishes. A start counts toward the limit until its run is recorded, which keeps concurrent starts under different names within the limit.

The limit lives in memory, so the Actor sets it again after a migration. Once the parent receives ABORTING, a start still waiting under the limit raises a RuntimeError without starting a run. Waiting further would block the abort of the other children.

Closes: #1138

✍️ Drafted by Claude Code

@vdusek vdusek added the t-tooling Issues with this label are in the ownership of the tooling team. label Sep 30, 2026
@vdusek vdusek self-assigned this Sep 30, 2026
@vdusek
vdusek added this pull request to stack #1152 September 30, 2026 09:49
@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.59%. Comparing base (4556821) to head (6d957ec).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1154      +/-   ##
==========================================
+ Coverage   93.42%   93.59%   +0.17%     
==========================================
  Files          54       54              
  Lines        3771     3858      +87     
==========================================
+ Hits         3523     3611      +88     
+ Misses        248      247       -1     
Flag Coverage Δ
e2e 31.51% <32.63%> (+0.04%) ⬆️
integration 50.95% <32.63%> (-0.39%) ⬇️
unit 90.48% <100.00%> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vdusek
vdusek force-pushed the feat/child-run-concurrency-limit branch from 5bbadc2 to 033624c Compare October 6, 2026 13:20
@vdusek
vdusek force-pushed the feat/child-run-concurrency-limit branch from 7322d8e to 7c3d462 Compare October 9, 2026 15:11
@vdusek
vdusek force-pushed the feat/child-run-concurrency-limit branch from 7c3d462 to 6f9300e Compare October 15, 2026 12:35
Base automatically changed from feat/abort-child-runs-with-parent to master October 29, 2026 13:19
@vdusek
vdusek force-pushed the feat/child-run-concurrency-limit branch from 6f9300e to c2f5a87 Compare October 29, 2026 13:38
@vdusek
vdusek marked this pull request as ready for review October 29, 2026 14:26
@apify-service-account apify-service-account added the tested Temporary label used only programatically for some analytics. label Oct 29, 2026
@vdusek
vdusek requested a review from barjin October 29, 2026 14:28
@vdusek
vdusek requested a review from Pijukatel October 29, 2026 14:29

@Pijukatel Pijukatel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am struggling a lot with the variable naming in this PR. Reading this with fresh eyes the variable names gave me a lot of false leads instead of building a helpful mental model.

The usage of word slot here had a very confusing effect on me. One time it was for an asyncio.Condition and the second time for a very custom context manager that uses the first one inside...
On top of it, slot has its own established meaning in Python, but here it is not related in any manner.

Comment thread src/apify/_child_runs.py Outdated
"""Client the run under each name was last started or looked up with. Lost on a migration."""
self._max_concurrent_runs: int | None = None
"""How many recorded runs may be active at once, or `None` for no limit."""
self._slots = asyncio.Condition()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds more a like an iterable, not a condition name. Can you please use some condition like name.

Maybe something like _slots_available ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to _capacity_available

Comment thread src/apify/_actor.py Outdated
"""
return self._child_run_registry.run_clients(self.apify_client)

def set_child_run_limits(self, *, max_concurrent_runs: int | None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The named in the description is critical. I would probably put it to the method name, because without reading the description you will get a wrong idea from just the method name. It is pretty uggly and explicit, but better than giving a wrong idea:
set_named_child_run_limits

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, renamed to set_named_child_run_limits

Comment thread src/apify/_child_runs.py Outdated
"""How many recorded runs may be active at once, or `None` for no limit."""
self._slots = asyncio.Condition()
"""Serializes counting the active runs, and wakes the starts waiting for a slot when one may have freed up."""
self._starting: set[str] = set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a state, not a set. Can we have a noun, so that we know what is starting?

Maybe something like _starting_names?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, renamed to _starting_names

Comment thread src/apify/_child_runs.py Outdated
self._observe(name, run)
if record.status != run.status:
await self._save(name, record.model_copy(update={'status': run.status}))
async with self._slots:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me the naming is extremely confusing. We have these similarly named context managers. async with self._slots, async with self._slot, but they are quite something different.

When having a singluar item _slot, than I expect that _slots will be just some structure holding multiple of _slot, but here it is just completelly different object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok - the two are now _capacity_available (the condition) and _reserve_capacity() (the context manager), so they don't read as singular and plural of the same thing anymore.

Comment thread src/apify/_child_runs.py Outdated
"""Serializes counting the active runs, and wakes the starts waiting for a slot when one may have freed up."""
self._starting: set[str] = set()
"""Names holding a slot for a start or resurrection in flight, not counted from the records yet."""
self._observed: dict[str, tuple[str, float]] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observed what? The name for this will be even more difficult, but observed tells too little...

Maybe timestamped_run_statuses?

@vdusek vdusek Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's what docstrings are for 🙂

self._observed: dict[str, tuple[str, float]] = {}
"""Last observed status of the run recorded under each name, with the event loop time it was observed at."""

but ok, _timestamped_run_statuses it is

Edit: in the end it is _run_status_cache and a new NamedTuple type, I believe this is even better

@vdusek
vdusek requested a review from Pijukatel October 30, 2026 08:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Limit the number of concurrent child runs

3 participants