Skip to content

Commit 93c94a1

Browse files
committed
fix: Apply queue backpressure before submit instead of bounding the work queue
1 parent 59646ee commit 93c94a1

3 files changed

Lines changed: 119 additions & 48 deletions

File tree

‎cloudquery/sdk/scheduler/scheduler.py‎

Lines changed: 13 additions & 1 deletion
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

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

2537

2638
class TableResolverStarted:
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: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,36 @@
1-
import gc
2-
import threading
3-
from typing import Any, Generator
1+
import subprocess
2+
import sys
43

5-
import pyarrow as pa
4+
import pytest
65

7-
from cloudquery.sdk.scheduler import Scheduler, TableResolver
6+
from cloudquery.sdk.scheduler import Scheduler
87
from cloudquery.sdk.scheduler.scheduler import QUEUE_PER_WORKER
9-
from cloudquery.sdk.schema import Column
10-
from cloudquery.sdk.schema.table import Table
11-
12-
13-
class ParentResolver(TableResolver):
14-
def __init__(self) -> None:
15-
super().__init__(
16-
Table("parent_table", [Column("id", pa.int64())]),
17-
child_resolvers=[ChildResolver()],
18-
)
19-
20-
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
21-
for i in range(3):
22-
yield {"id": i}
23-
24-
25-
class ChildResolver(TableResolver):
26-
def __init__(self) -> None:
27-
super().__init__(Table("child_table", [Column("id", pa.int64())]))
28-
29-
def resolve(self, client, parent_resource) -> Generator[Any, None, None]:
30-
yield {"id": 1}
31-
32-
33-
class QueueSizeTestClient:
34-
def id(self):
35-
return "test_client"
368

379

3810
def test_default_queue_size_is_used_for_all_depths():
3911
concurrency = 10
4012
s = Scheduler(concurrency, max_depth=3)
4113
expected_size = concurrency * QUEUE_PER_WORKER
4214
for pool in s._pools:
43-
assert pool._work_queue.maxsize == expected_size
15+
assert pool.maxsize == expected_size
4416
expected_size = expected_size // 2 if expected_size > 1 else 1
4517
s.shutdown()
4618

4719

48-
def test_dropping_used_scheduler_does_not_block():
49-
holder = {}
50-
for _ in range(10):
51-
holder["scheduler"] = Scheduler(10)
52-
list(holder["scheduler"].sync(QueueSizeTestClient(), [ParentResolver()]))
53-
54-
def replace_scheduler():
55-
holder["scheduler"] = Scheduler(10)
56-
gc.collect()
57-
58-
thread = threading.Thread(target=replace_scheduler, daemon=True)
59-
thread.start()
60-
thread.join(timeout=5)
61-
assert not thread.is_alive()
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)