Skip to content

Commit 0a4b408

Browse files
authored
fix: Use the computed queue size for child resolver pools (#416)
Child resolver pools used the raw `queue_size` argument and every pool bounded its executor work queue, which could deadlock a sync or block dropping a scheduler, so this sizes every pool from the computed queue size and applies that limit as a semaphore before `submit` while keeping the executor's work queue unbounded.
1 parent bbd5b2e commit 0a4b408

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

‎cloudquery/sdk/scheduler/scheduler.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import queue
2+
import threading
23
from concurrent import futures
34
from typing import List, Generator, Any, Optional
45

@@ -21,7 +22,18 @@
2122
class ThreadPoolExecutorWithQueueSizeLimit(futures.ThreadPoolExecutor):
2223
def __init__(self, maxsize, *args, **kwargs):
2324
super().__init__(*args, **kwargs)
24-
self._work_queue = queue.Queue(maxsize=maxsize)
25+
self.maxsize = maxsize
26+
self._slots = threading.BoundedSemaphore(maxsize + self._max_workers)
27+
28+
def submit(self, fn, /, *args, **kwargs):
29+
self._slots.acquire()
30+
try:
31+
future = super().submit(fn, *args, **kwargs)
32+
except BaseException:
33+
self._slots.release()
34+
raise
35+
future.add_done_callback(lambda _: self._slots.release())
36+
return future
2537

2638

2739
class TableResolverStarted:
@@ -61,7 +73,7 @@ def __init__(
6173
)
6274
self._pools: List[ThreadPoolExecutorWithQueueSizeLimit] = []
6375
current_depth_concurrency = concurrency
64-
current_depth_queue_size = queue_size
76+
current_depth_queue_size = self._queue_size
6577
for _ in range(max_depth + 1):
6678
self._pools.append(
6779
ThreadPoolExecutorWithQueueSizeLimit(
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import gc
2+
import sys
3+
import threading
4+
from typing import Any, Generator, List
5+
6+
import pyarrow as pa
7+
8+
from cloudquery.sdk.scheduler import Scheduler, TableResolver
9+
from cloudquery.sdk.schema import Column
10+
from cloudquery.sdk.schema.table import Table
11+
12+
13+
class ScenarioClient:
14+
def __init__(self, index: int = 0) -> None:
15+
self.index = index
16+
17+
def id(self):
18+
return f"client_{self.index}"
19+
20+
21+
class ScenarioResolver(TableResolver):
22+
def __init__(
23+
self,
24+
name: str,
25+
items: int,
26+
child_resolvers: List[TableResolver] = None,
27+
clients: int = 1,
28+
) -> None:
29+
super().__init__(
30+
Table(name, [Column("id", pa.int64())]),
31+
child_resolvers=child_resolvers or [],
32+
)
33+
self.items = items
34+
self.clients = clients
35+
36+
def multiplex(self, client):
37+
return [ScenarioClient(i) for i in range(self.clients)]
38+
39+
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
40+
for i in range(self.items):
41+
yield {"id": i}
42+
43+
44+
def parent_with_child(clients: int = 1) -> ScenarioResolver:
45+
return ScenarioResolver(
46+
"parent", 3, [ScenarioResolver("child", 1)], clients=clients
47+
)
48+
49+
50+
def sync_all(scheduler: Scheduler, resolver: TableResolver) -> None:
51+
list(scheduler.sync(ScenarioClient(), [resolver]))
52+
53+
54+
def drop_used_scheduler(queue_size: int = 0) -> None:
55+
holder = {}
56+
for _ in range(10):
57+
holder["scheduler"] = Scheduler(10, queue_size=queue_size)
58+
sync_all(holder["scheduler"], parent_with_child())
59+
60+
def replace_scheduler():
61+
holder["scheduler"] = Scheduler(10, queue_size=queue_size)
62+
gc.collect()
63+
64+
thread = threading.Thread(target=replace_scheduler)
65+
thread.start()
66+
thread.join()
67+
68+
69+
def drop_used_scheduler_with_small_queue() -> None:
70+
drop_used_scheduler(queue_size=1)
71+
72+
73+
def more_clients_than_top_level_queue() -> None:
74+
sync_all(Scheduler(2), parent_with_child(clients=1000))
75+
76+
77+
def grandchildren_with_small_queue() -> None:
78+
grandchild = ScenarioResolver("grandchild", 3)
79+
child = ScenarioResolver("child", 3, [grandchild])
80+
sync_all(Scheduler(4, queue_size=1), ScenarioResolver("parent", 10, [child]))
81+
82+
83+
if __name__ == "__main__":
84+
globals()[sys.argv[1]]()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import subprocess
2+
import sys
3+
4+
import pytest
5+
6+
from cloudquery.sdk.scheduler import Scheduler
7+
from cloudquery.sdk.scheduler.scheduler import QUEUE_PER_WORKER
8+
9+
10+
def test_default_queue_size_is_used_for_all_depths():
11+
concurrency = 10
12+
s = Scheduler(concurrency, max_depth=3)
13+
expected_size = concurrency * QUEUE_PER_WORKER
14+
for pool in s._pools:
15+
assert pool.maxsize == expected_size
16+
expected_size = expected_size // 2 if expected_size > 1 else 1
17+
s.shutdown()
18+
19+
20+
@pytest.mark.parametrize(
21+
"scenario",
22+
[
23+
"drop_used_scheduler",
24+
"drop_used_scheduler_with_small_queue",
25+
"more_clients_than_top_level_queue",
26+
"grandchildren_with_small_queue",
27+
],
28+
)
29+
def test_scenario_does_not_hang(scenario):
30+
result = subprocess.run(
31+
[sys.executable, "-m", "tests.scheduler.queue_size_scenarios", scenario],
32+
capture_output=True,
33+
text=True,
34+
timeout=60,
35+
)
36+
assert result.returncode == 0, result.stderr

0 commit comments

Comments
 (0)