Skip to content

perf: allow concurrent dataset pushes by narrowing the push-path locks - #1079

Open
vdusek wants to merge 4 commits into
masterfrom
worktree-fix-p1
Open

perf: allow concurrent dataset pushes by narrowing the push-path locks#1079
vdusek wants to merge 4 commits into
masterfrom
worktree-fix-p1

Conversation

@vdusek

@vdusek vdusek commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Concurrent dataset pushes were fully serialized - N concurrent pushes cost N sequential API round-trips.

Problem

Two locks wrapped the network I/O; either alone is enough to serialize, so both had to be narrowed:

  • Actor.push_data held charging_manager.charge_lock() across the whole push + charge sequence.
  • ApifyDatasetClient.push_data held its per-client _lock across the chunked push_items calls.

The charge lock only matters for pay-per-event runs (keeps the limit reservation and charge atomic). ApifyDatasetClient.push_data mutates no client state - _serialize_chunk is a classmethod, push_items is a stateless API call - so outside of charging, neither lock protects anything.

Change

  • Both call sites now go through one charge_lock_if_charging() helper in _charging.py, keyed off charging_manager_ctx.
  • Replaces the near-duplicate DatasetClientPpeMixin._charge_lock and a second, divergent is_pay_per_event predicate - the "do we need the lock?" decision now lives in one place.
  • Pay-per-event runs are unchanged and still fully serialized.

Benchmark

push_data against the real API, 10 items/push, median of 5 rounds (raw = same payloads sent directly via push_items, the network floor with no lock involved):

concurrency master this PR raw floor speedup
1 145 ms 130 ms 130 ms 1.1x
4 619 ms 150 ms 138 ms 4.1x
8 1109 ms 153 ms 141 ms 7.3x
16 2216 ms 190 ms 168 ms 11.7x
32 4502 ms 188 ms 186 ms 24.0x

Master's curve is N x single-push latency (full serialization); this PR flattens onto the raw floor. Re-adding just one lock (either one) around push_data reproduces master's numbers exactly, confirming both had to move.

Behavior changes

  • Pushes and drop() are no longer mutually exclusive. The lock only ever ordered them within one client instance in one process, so a concurrent drop was never actually safe. _lock still serializes drop() itself. Verified against the real API: 8 concurrent pushes racing a drop() finish in under a second, with the pushes failing NotFoundError once the drop wins, instead of silently completing first.
  • A push's chunks are no longer contiguous in the dataset when other pushes run concurrently (16 concurrent multi-chunk pushes land as 224 interleaved blocks instead of 16). Item order within one push is still preserved (new test below); cross-push interleaving was never a documented guarantee.
  • Local file-system runs are unaffected - still serialized by Crawlee's FileSystemDatasetClient._lock, which guards sequential item numbering and metadata and has no network round-trip behind it.

Testing

  • test_concurrent_push_data_overlaps, test_push_data_does_not_take_charge_lock_without_pay_per_event - each fails on master.
  • test_concurrent_actor_push_data_stays_within_budget - fails if the charge lock is ever skipped for PPE runs.
  • test_concurrent_multi_chunk_pushes_preserve_per_push_order - locks in per-push ordering on the new concurrent-chunk code path this PR enables.
  • Stress-tested against the real API beyond unit tests: 2400-item/32-way concurrent integrity check (no loss, no dupes), 640-item multi-chunk check, 256-way soak (7680 items, 0 errors), and 270 concurrent-lifecycle PPE-budget iterations across explicit/synthetic/mixed charge paths - all stayed within budget, identically on master and this PR.

✍️ Drafted by Claude Code

@vdusek vdusek added adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team. labels Aug 4, 2026
@vdusek vdusek self-assigned this Aug 4, 2026
@github-actions github-actions Bot added this to the 146th sprint - Tooling team milestone Aug 4, 2026
@github-actions github-actions Bot added the tested Temporary label used only programatically for some analytics. label Aug 4, 2026
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.14%. Comparing base (439ee7b) to head (d08d9ab).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1079      +/-   ##
==========================================
+ Coverage   91.98%   92.14%   +0.15%     
==========================================
  Files          51       51              
  Lines        3232     3234       +2     
==========================================
+ Hits         2973     2980       +7     
+ Misses        259      254       -5     
Flag Coverage Δ
e2e 35.31% <37.50%> (+<0.01%) ⬆️
integration 56.95% <75.00%> (+0.05%) ⬆️
unit 84.35% <100.00%> (+0.13%) ⬆️

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 changed the title fix: skip the charge lock in Actor.push_data for non-pay-per-event runs perf: allow concurrent dataset pushes by narrowing the push-path locks Aug 4, 2026
@vdusek
vdusek requested a review from Mantisus August 5, 2026 11:49
@vdusek
vdusek marked this pull request as ready for review August 5, 2026 11:49

@Mantisus Mantisus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Just a couple of nits

Comment on lines 57 to +58
self._lock = lock
"""A lock to ensure that only one operation is performed at a time."""
"""A lock serializing destructive operations on the dataset."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does drop really need a lock now? If I'm not mistaken, it was there to keep the dataset from being dropped while a push was in progress.

Comment on lines +132 to +140
chunk_count = 0

async def push_items(**kwargs: Any) -> None:
nonlocal chunk_count
chunk_count += 1
await asyncio.sleep(0) # Yield so chunks from other concurrent pushes can land in between.
received.extend((item['push'], item['i']) for item in json.loads(kwargs['items']))

api_client.push_items = push_items

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's try doing this using unittest.mock.patch, but I won't insist 🙂

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

Labels

adhoc Ad-hoc unplanned task added during the sprint. 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.

3 participants