Skip to content

Commit 85aa600

Browse files
CopilotiMicknl
andcommitted
Fix concurrency issues and simplify API in ActionQueue implementation (#1876)
- [x] Update integration tests to match the new simplified API - Updated test_client_with_queue_batches_actions to use asyncio.create_task for concurrent execution - Updated test_client_manual_flush to start execution as a task before checking pending count - Updated test_client_close_flushes_queue to work with str return type instead of QueuedExecution - Updated test_client_queue_respects_max_actions to handle direct exec_id returns - All 15 queue-related tests now passing (10 unit tests + 5 integration tests) <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/iMicknl/python-overkiz-api/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: iMicknl <[email protected]>
1 parent 2e53ea5 commit 85aa600

File tree

5 files changed

+187
-114
lines changed

5 files changed

+187
-114
lines changed

pyoverkiz/action_queue.py

Lines changed: 109 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import contextlib
67
from collections.abc import Callable, Coroutine
78
from typing import TYPE_CHECKING
89

@@ -23,11 +24,15 @@ def set_result(self, exec_id: str) -> None:
2324
if not self._future.done():
2425
self._future.set_result(exec_id)
2526

26-
def set_exception(self, exception: Exception) -> None:
27+
def set_exception(self, exception: BaseException) -> None:
2728
"""Set an exception if the batch execution failed."""
2829
if not self._future.done():
2930
self._future.set_exception(exception)
3031

32+
def is_done(self) -> bool:
33+
"""Check if the execution has completed (either with result or exception)."""
34+
return self._future.done()
35+
3136
def __await__(self):
3237
"""Make this awaitable."""
3338
return self._future.__await__()
@@ -84,64 +89,92 @@ async def add(
8489
:param label: Label for the action group
8590
:return: QueuedExecution that resolves to exec_id when batch executes
8691
"""
92+
batch_to_execute = None
93+
8794
async with self._lock:
8895
# If mode or label changes, flush existing queue first
8996
if self._pending_actions and (
9097
mode != self._pending_mode or label != self._pending_label
9198
):
92-
await self._flush_now()
99+
batch_to_execute = self._prepare_flush()
93100

94101
# Add actions to pending queue
95102
self._pending_actions.extend(actions)
96103
self._pending_mode = mode
97104
self._pending_label = label
98105

99-
# Create waiter for this caller
106+
# Create waiter for this caller. This waiter is added to the current
107+
# batch being built, even if we flushed a previous batch above due to
108+
# a mode/label change. This ensures the waiter belongs to the batch
109+
# containing the actions we just added.
100110
waiter = QueuedExecution()
101111
self._pending_waiters.append(waiter)
102112

103113
# If we hit max actions, flush immediately
104114
if len(self._pending_actions) >= self._max_actions:
105-
await self._flush_now()
106-
else:
115+
# Prepare the current batch for flushing (which includes the actions
116+
# we just added). If we already flushed due to mode change, this is
117+
# a second batch.
118+
new_batch = self._prepare_flush()
119+
# Execute the first batch if it exists, then the second
120+
if batch_to_execute:
121+
await self._execute_batch(*batch_to_execute)
122+
batch_to_execute = new_batch
123+
elif self._flush_task is None or self._flush_task.done():
107124
# Schedule delayed flush if not already scheduled
108-
if self._flush_task is None or self._flush_task.done():
109-
self._flush_task = asyncio.create_task(self._delayed_flush())
125+
self._flush_task = asyncio.create_task(self._delayed_flush())
126+
127+
# Execute batch outside the lock if we flushed
128+
if batch_to_execute:
129+
await self._execute_batch(*batch_to_execute)
110130

111-
return waiter
131+
return waiter
112132

113133
async def _delayed_flush(self) -> None:
114134
"""Wait for the delay period, then flush the queue."""
115-
await asyncio.sleep(self._delay)
116-
async with self._lock:
117-
if not self._pending_actions:
118-
return
119-
120-
# Take snapshot and clear state while holding lock
121-
actions = self._pending_actions
122-
mode = self._pending_mode
123-
label = self._pending_label
124-
waiters = self._pending_waiters
125-
126-
self._pending_actions = []
127-
self._pending_mode = None
128-
self._pending_label = None
129-
self._pending_waiters = []
130-
self._flush_task = None
131-
132-
# Execute outside the lock
135+
waiters: list[QueuedExecution] = []
133136
try:
134-
exec_id = await self._executor(actions, mode, label)
135-
for waiter in waiters:
136-
waiter.set_result(exec_id)
137-
except Exception as exc:
137+
await asyncio.sleep(self._delay)
138+
async with self._lock:
139+
if not self._pending_actions:
140+
return
141+
142+
# Take snapshot and clear state while holding lock
143+
actions = self._pending_actions
144+
mode = self._pending_mode
145+
label = self._pending_label
146+
waiters = self._pending_waiters
147+
148+
self._pending_actions = []
149+
self._pending_mode = None
150+
self._pending_label = None
151+
self._pending_waiters = []
152+
self._flush_task = None
153+
154+
# Execute outside the lock
155+
try:
156+
exec_id = await self._executor(actions, mode, label)
157+
for waiter in waiters:
158+
waiter.set_result(exec_id)
159+
except Exception as exc:
160+
for waiter in waiters:
161+
waiter.set_exception(exc)
162+
except asyncio.CancelledError as exc:
163+
# Ensure all waiters are notified if this task is cancelled
138164
for waiter in waiters:
139165
waiter.set_exception(exc)
166+
raise
167+
168+
def _prepare_flush(
169+
self,
170+
) -> tuple[list[Action], CommandMode | None, str | None, list[QueuedExecution]]:
171+
"""Prepare a flush by taking snapshot and clearing state (must be called with lock held).
140172
141-
async def _flush_now(self) -> None:
142-
"""Execute pending actions immediately (must be called with lock held)."""
173+
Returns a tuple of (actions, mode, label, waiters) that should be executed
174+
outside the lock using _execute_batch().
175+
"""
143176
if not self._pending_actions:
144-
return
177+
return ([], None, None, [])
145178

146179
# Cancel any pending flush task
147180
if self._flush_task and not self._flush_task.done():
@@ -160,8 +193,19 @@ async def _flush_now(self) -> None:
160193
self._pending_label = None
161194
self._pending_waiters = []
162195

163-
# Execute the batch (must release lock before calling executor to avoid deadlock)
164-
# Note: This is called within a lock context, we'll execute outside
196+
return (actions, mode, label, waiters)
197+
198+
async def _execute_batch(
199+
self,
200+
actions: list[Action],
201+
mode: CommandMode | None,
202+
label: str | None,
203+
waiters: list[QueuedExecution],
204+
) -> None:
205+
"""Execute a batch of actions and notify waiters (must be called without lock)."""
206+
if not actions:
207+
return
208+
165209
try:
166210
exec_id = await self._executor(actions, mode, label)
167211
# Notify all waiters
@@ -173,42 +217,49 @@ async def _flush_now(self) -> None:
173217
waiter.set_exception(exc)
174218
raise
175219

176-
async def flush(self) -> list[str]:
220+
async def flush(self) -> None:
177221
"""Force flush all pending actions immediately.
178222
179-
:return: List of exec_ids from flushed batches
223+
This method forces the queue to execute any pending batched actions
224+
without waiting for the delay timer. The execution results are delivered
225+
to the corresponding QueuedExecution objects returned by add().
226+
227+
This method is useful for forcing immediate execution without having to
228+
wait for the delay timer to expire.
180229
"""
230+
batch_to_execute = None
181231
async with self._lock:
182-
if not self._pending_actions:
183-
return []
184-
185-
# Since we can only have one batch pending at a time,
186-
# this will return a single exec_id in a list
187-
exec_ids: list[str] = []
232+
if self._pending_actions:
233+
batch_to_execute = self._prepare_flush()
188234

189-
try:
190-
await self._flush_now()
191-
# If flush succeeded, we can't actually return the exec_id here
192-
# since it's delivered via the waiters. This method is mainly
193-
# for forcing a flush, not retrieving results.
194-
# Return empty list to indicate flush completed
195-
except Exception:
196-
# If flush fails, the exception will be propagated to waiters
197-
# and also raised here
198-
raise
199-
200-
return exec_ids
235+
# Execute outside the lock
236+
if batch_to_execute:
237+
await self._execute_batch(*batch_to_execute)
201238

202239
def get_pending_count(self) -> int:
203-
"""Get the number of actions currently waiting in the queue."""
240+
"""Get the (approximate) number of actions currently waiting in the queue.
241+
242+
This method does not acquire the internal lock and therefore returns a
243+
best-effort snapshot that may be slightly out of date if the queue is
244+
being modified concurrently by other coroutines.
245+
"""
204246
return len(self._pending_actions)
205247

206248
async def shutdown(self) -> None:
207249
"""Shutdown the queue, flushing any pending actions."""
250+
batch_to_execute = None
208251
async with self._lock:
209252
if self._flush_task and not self._flush_task.done():
210-
self._flush_task.cancel()
253+
task = self._flush_task
254+
task.cancel()
211255
self._flush_task = None
256+
# Wait for cancellation to complete
257+
with contextlib.suppress(asyncio.CancelledError):
258+
await task
212259

213260
if self._pending_actions:
214-
await self._flush_now()
261+
batch_to_execute = self._prepare_flush()
262+
263+
# Execute outside the lock
264+
if batch_to_execute:
265+
await self._execute_batch(*batch_to_execute)

pyoverkiz/client.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
ServerDisconnectedError,
2020
)
2121

22-
from pyoverkiz.action_queue import ActionQueue, QueuedExecution
22+
from pyoverkiz.action_queue import ActionQueue
2323
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
2424
from pyoverkiz.const import SUPPORTED_SERVERS
2525
from pyoverkiz.enums import APIType, CommandMode, Server
@@ -161,7 +161,7 @@ def __init__(
161161
:param session: optional ClientSession
162162
:param action_queue_enabled: enable action batching queue (default False)
163163
:param action_queue_delay: seconds to wait before flushing queue (default 0.5)
164-
:param action_queue_max_actions: max actions per batch (default 20)
164+
:param action_queue_max_actions: maximum actions per batch before auto-flush (default 20)
165165
"""
166166
self.server_config = self._normalize_server(server)
167167

@@ -183,6 +183,11 @@ def __init__(
183183

184184
# Initialize action queue if enabled
185185
if action_queue_enabled:
186+
if action_queue_delay <= 0:
187+
raise ValueError("action_queue_delay must be positive")
188+
if action_queue_max_actions < 1:
189+
raise ValueError("action_queue_max_actions must be at least 1")
190+
186191
self._action_queue = ActionQueue(
187192
executor=self._execute_action_group_direct,
188193
delay=action_queue_delay,
@@ -487,22 +492,31 @@ async def execute_action_group(
487492
actions: list[Action],
488493
mode: CommandMode | None = None,
489494
label: str | None = "python-overkiz-api",
490-
) -> str | QueuedExecution:
495+
) -> str:
491496
"""Execute a non-persistent action group.
492497
493-
If action queue is enabled, actions will be batched with other actions
494-
executed within the configured delay window. Returns a QueuedExecution
495-
that can be awaited to get the exec_id.
498+
When action queue is enabled, actions will be batched with other actions
499+
executed within the configured delay window. The method will wait for the
500+
batch to execute and return the exec_id.
501+
502+
When action queue is disabled, executes immediately and returns exec_id.
496503
497-
If action queue is disabled, executes immediately and returns exec_id directly.
504+
The API is consistent regardless of queue configuration - always returns
505+
exec_id string directly.
498506
499507
:param actions: List of actions to execute
500508
:param mode: Command mode (GEOLOCATED, INTERNAL, HIGH_PRIORITY, or None)
501509
:param label: Label for the action group
502-
:return: exec_id string (if queue disabled) or QueuedExecution (if queue enabled)
510+
:return: exec_id string from the executed action group
511+
512+
Example usage::
513+
514+
# Works the same with or without queue
515+
exec_id = await client.execute_action_group([action])
503516
"""
504517
if self._action_queue:
505-
return await self._action_queue.add(actions, mode, label)
518+
queued = await self._action_queue.add(actions, mode, label)
519+
return await queued
506520
else:
507521
return await self._execute_action_group_direct(actions, mode, label)
508522

test_queue_example.py

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22
# mypy: ignore-errors
3-
# ty: ignore
3+
# type: ignore
44

55
"""Simple example demonstrating the action queue feature."""
66

@@ -26,7 +26,7 @@ async def example_without_queue():
2626
)
2727

2828
# Create some example actions
29-
_action1 = Action(
29+
Action(
3030
device_url="io://1234-5678-9012/12345678",
3131
commands=[Command(name=OverkizCommand.CLOSE)],
3232
)
@@ -71,28 +71,22 @@ async def example_with_queue():
7171

7272
# These will be queued and batched together!
7373
print("Queueing action 1...")
74-
queued1 = await client.execute_action_group([action1])
75-
print(f"Got QueuedExecution object: {queued1}")
74+
exec_id1 = await client.execute_action_group([action1])
75+
print(f"Got exec_id: {exec_id1}")
7676

7777
print("Queueing action 2...")
78-
_queued2 = await client.execute_action_group([action2])
78+
exec_id2 = await client.execute_action_group([action2])
7979

8080
print("Queueing action 3...")
81-
_queued3 = await client.execute_action_group([action3])
81+
exec_id3 = await client.execute_action_group([action3])
8282

8383
print(f"Pending actions in queue: {client.get_pending_actions_count()}")
8484

85-
# Wait for all actions to execute (they'll be batched together)
86-
print("\nWaiting for batch to execute...")
87-
# exec_id1 = await queued1
88-
# exec_id2 = await queued2
89-
# exec_id3 = await queued3
90-
9185
# All three will have the same exec_id since they were batched together!
92-
# print(f"Exec ID 1: {exec_id1}")
93-
# print(f"Exec ID 2: {exec_id2}")
94-
# print(f"Exec ID 3: {exec_id3}")
95-
# print(f"All same? {exec_id1 == exec_id2 == exec_id3}")
86+
print(f"\nExec ID 1: {exec_id1}")
87+
print(f"Exec ID 2: {exec_id2}")
88+
print(f"Exec ID 3: {exec_id3}")
89+
print(f"All same? {exec_id1 == exec_id2 == exec_id3}")
9690

9791
print("\nWith queue: Multiple actions batched into single API request!")
9892
await client.close()
@@ -116,8 +110,11 @@ async def example_manual_flush():
116110
)
117111

118112
print("Queueing action with 10s delay...")
119-
_queued = await client.execute_action_group([action])
113+
# Start execution in background (don't await yet)
114+
exec_task = asyncio.create_task(client.execute_action_group([action]))
120115

116+
# Give it a moment to queue
117+
await asyncio.sleep(0.1)
121118
print(f"Pending actions: {client.get_pending_actions_count()}")
122119

123120
# Don't want to wait 10 seconds? Flush manually!
@@ -126,9 +123,9 @@ async def example_manual_flush():
126123

127124
print(f"Pending actions after flush: {client.get_pending_actions_count()}")
128125

129-
# Now we can await the result
130-
# exec_id = await queued
131-
# print(f"Got exec_id: {exec_id}")
126+
# Now get the result
127+
exec_id = await exec_task
128+
print(f"Got exec_id: {exec_id}")
132129

133130
await client.close()
134131

0 commit comments

Comments
 (0)