Skip to content

Commit 8eca80d

Browse files
committed
fix: remove idle waits from checkpoint batching
After collecting a synchronous checkpoint, the collector waited up to 100 ms on an empty queue. The caller waits until the batch persists, so it cannot add work. The wait only delayed it. Sequential steps paid it once per step. Once a batch holds a synchronous checkpoint, wait 1 ms on an empty queue instead. A batch with no blocked caller keeps the full window, so a step's asynchronous START still shares a request with its SUCCEED. With a 1 ms window, refreshes from independent coordinators can split into separate requests when they arrive more than 1 ms apart. A refresh is the empty checkpoint a coordinator sends to see that a wait has ended. The coordinator knows the end time when the branch suspends, so it now requests the refresh then, with that time attached, through ExecutionState.schedule_refresh. The collector holds refreshes until their time and sends all refreshes due at one time in one request. A refresh scheduled before a batch is sealed joins it. Failure, completion and shutdown settle every pending refresh. On Lambda at 1024 MB, 1000 sequential steps went from 142 ms to 41 ms per step. Ten nested coordinators resumed a wave 89 to 104 ms after its end time instead of 288 to 303 ms. Fixes #710
1 parent 8742ad9 commit 8eca80d

5 files changed

Lines changed: 1338 additions & 436 deletions

File tree

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/concurrency/executor.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import time
99
from collections import deque
1010
from concurrent.futures import ThreadPoolExecutor
11+
from dataclasses import dataclass, field
1112
from typing import TYPE_CHECKING, Generic, TypeVar, cast
1213

1314
from aws_durable_execution_sdk_python.concurrency.models import (
@@ -56,6 +57,7 @@
5657
from aws_durable_execution_sdk_python.state import (
5758
CheckpointedResult,
5859
ExecutionState,
60+
ScheduledRefresh,
5961
)
6062

6163

@@ -86,6 +88,20 @@ def _branch_error_object(err: Exception) -> ErrorObject:
8688
return ErrorObject.from_exception(err)
8789

8890

91+
@dataclass
92+
class ResumeWave(Generic[CallableType, ResultType]):
93+
"""Branches suspended until one time, and the refresh that resumes them.
94+
95+
The refresh is a delayed empty checkpoint requested when the first branch
96+
suspends. Its response shows the waits complete, so the wave resumes one
97+
round trip after its time.
98+
"""
99+
100+
resume_at: float
101+
refresh: ScheduledRefresh
102+
branches: list[Branch[CallableType, ResultType]] = field(default_factory=list)
103+
104+
89105
class ConcurrentExecutor(Generic[CallableType, ResultType]):
90106
"""Execute durable operations concurrently. This contains the execution logic for Map and Parallel.
91107
@@ -252,7 +268,9 @@ def execute(
252268

253269
events: queue.Queue[BranchEvent[ResultType]] = queue.Queue()
254270
pending: deque[Branch[CallableType, ResultType]] = deque(self.branches)
255-
timed_resumes: list[tuple[float, int]] = []
271+
# The heap orders resume times. The dict groups the branches under each.
272+
resume_times: list[float] = []
273+
waves: dict[float, ResumeWave[CallableType, ResultType]] = {}
256274
branch_by_index: dict[int, Branch[CallableType, ResultType]] = {
257275
branch.index: branch for branch in self.branches
258276
}
@@ -317,19 +335,20 @@ def submit(branch: Branch[CallableType, ResultType]) -> None:
317335
running += 1
318336
needs_snapshot_rebuild = True
319337

320-
# Resume due timed suspends in-process. One checkpoint
321-
# refresh serves the whole due wave; a failure is terminal
338+
# Resume due waves in-process. A refresh failure is terminal
322339
# for the execution and propagates from this thread.
323340
now: float = time.time()
324-
due: list[Branch[CallableType, ResultType]] = []
325-
while timed_resumes and timed_resumes[0][0] <= now:
326-
_, index = heapq.heappop(timed_resumes)
327-
due.append(branch_by_index[index])
328-
if due:
329-
execution_state.create_checkpoint()
330-
for branch in due:
341+
resumed = False
342+
while resume_times and resume_times[0] <= now:
343+
wave = waves.pop(heapq.heappop(resume_times))
344+
wave.refresh.wait()
345+
# Branches joined the wave in event order. Resume in index
346+
# order so scheduling stays deterministic.
347+
for branch in sorted(wave.branches, key=lambda b: b.index):
331348
submit(branch)
332349
running += 1
350+
resumed = True
351+
if resumed:
333352
continue
334353

335354
if running == 0:
@@ -340,18 +359,18 @@ def submit(branch: Branch[CallableType, ResultType]) -> None:
340359
raise retryable_error
341360
# Every in-flight branch is suspended and no slot is
342361
# free (or no work remains): suspend the parent.
343-
if timed_resumes:
362+
if resume_times:
344363
raise TimedSuspendExecution(
345364
"All concurrent work complete or suspended pending retry.",
346-
timed_resumes[0][0],
365+
resume_times[0],
347366
)
348367
raise SuspendExecution(
349368
"All concurrent work complete or suspended and pending external callback."
350369
)
351370

352371
timeout: float | None = None
353-
if timed_resumes:
354-
timeout = max(timed_resumes[0][0] - time.time(), 0)
372+
if resume_times:
373+
timeout = max(resume_times[0] - time.time(), 0)
355374
try:
356375
event: BranchEvent[ResultType] = events.get(timeout=timeout)
357376
except queue.Empty:
@@ -383,7 +402,13 @@ def submit(branch: Branch[CallableType, ResultType]) -> None:
383402
needs_snapshot_rebuild = True
384403
case BranchEventKind.SUSPENDED_UNTIL if event.resume_at is not None:
385404
applied.suspend_until(event.resume_at)
386-
heapq.heappush(timed_resumes, (event.resume_at, event.index))
405+
if event.resume_at not in waves:
406+
waves[event.resume_at] = ResumeWave(
407+
event.resume_at,
408+
execution_state.schedule_refresh(event.resume_at),
409+
)
410+
heapq.heappush(resume_times, event.resume_at)
411+
waves[event.resume_at].branches.append(applied)
387412
running -= 1
388413
needs_snapshot_rebuild = True
389414
case BranchEventKind.ORPHANED:
@@ -402,6 +427,9 @@ def submit(branch: Branch[CallableType, ResultType]) -> None:
402427
msg = f"Unhandled branch event: {event}"
403428
raise InvalidStateError(msg)
404429
finally:
430+
# Nothing will wait for a refresh whose wave never resumed.
431+
for wave in waves.values():
432+
wave.refresh.cancel()
405433
# Shutdown without waiting for running threads for early return
406434
# when completion criteria are met (e.g., min_successful).
407435
# Running threads continue in the background of this invocation

0 commit comments

Comments
 (0)