Skip to content

Commit ea793e4

Browse files
committed
feat: add AsyncQueue for in-process background workers
Implementation: - Add AsyncQueue (queues/async_queue.py) with asyncio.Queue - Background workers pull events and call processor.process_event() - Configurable worker count via EVENTKIT_ASYNC_WORKERS (default: 4) - Graceful shutdown with queue draining - Update factory to create AsyncQueue for ASYNC mode Tests (8 new tests, all passing): - test_async.py: Queue operations, workers, error handling, shutdown - test_factory.py: Updated to test AsyncQueue creation Coverage: - AsyncQueue: 96% (45 statements, 2 missing) - Overall: 98% coverage (548 statements, 13 missing) Key Features: - Fast API responses (enqueue is O(1), returns immediately) - Parallel processing with N configurable workers - Error isolation (one failed event doesn't stop workers) - Clean shutdown (waits for queue to drain) Usage: EVENTKIT_QUEUE_MODE=async EVENTKIT_ASYNC_WORKERS=8 queue = create_queue(processor, settings) await queue.start() # Starts 8 workers await queue.enqueue(event) # Returns immediately await queue.stop() # Drains queue, then stops All 136 unit tests passing ✅
1 parent b2e143e commit ea793e4

8 files changed

Lines changed: 393 additions & 21 deletions

File tree

‎src/eventkit/config.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ class QueueMode(str, Enum):
1414
"""
1515
Queue backend mode for event processing.
1616
17-
DIRECT: Inline processing (no actual queue) - v0.1.0 default
18-
ASYNC: In-process workers with asyncio.Queue - future
19-
PUBSUB: Distributed workers with GCP Pub/Sub - future
17+
DIRECT: Inline processing (no actual queue) - default
18+
ASYNC: In-process workers with asyncio.Queue
19+
PUBSUB: Distributed workers with GCP Pub/Sub (future)
2020
"""
2121

2222
DIRECT = "direct"
@@ -57,6 +57,7 @@ class Settings(BaseSettings):
5757

5858
# Queue configuration
5959
EVENTKIT_QUEUE_MODE: QueueMode = QueueMode.DIRECT # Queue backend mode
60+
EVENTKIT_ASYNC_WORKERS: int = 4 # Number of workers for AsyncQueue mode
6061

6162
# Logging
6263
LOG_LEVEL: str = "INFO"

‎src/eventkit/queues/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Event queue abstractions for pluggable queue backends."""
22

3+
from eventkit.queues.async_queue import AsyncQueue
34
from eventkit.queues.base import EventQueue
45
from eventkit.queues.direct import DirectQueue
56
from eventkit.queues.factory import create_queue
67

7-
__all__ = ["EventQueue", "DirectQueue", "create_queue"]
8+
__all__ = ["EventQueue", "DirectQueue", "AsyncQueue", "create_queue"]

‎src/eventkit/queues/async_queue.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
AsyncQueue - In-process event processing with background workers.
3+
4+
This queue implementation uses asyncio.Queue to decouple API request handling
5+
from event processing. Events are enqueued quickly, then processed by background
6+
workers in parallel.
7+
8+
Use Case:
9+
- Single-server deployment with concurrent processing
10+
- Decouple API response time from processing time
11+
- Parallel processing with configurable workers
12+
13+
Trade-offs:
14+
- ✅ Fast API responses (enqueue is O(1))
15+
- ✅ Parallel processing (multiple workers)
16+
- ✅ Simple: No external dependencies
17+
- ❌ No persistence: Events lost on crash
18+
- ❌ No horizontal scaling: Single process only
19+
20+
Evolution Path:
21+
- DirectQueue: Inline processing (no queue)
22+
- AsyncQueue: In-process workers (this file)
23+
- PubSubQueue: Distributed workers (future)
24+
"""
25+
26+
import asyncio
27+
import logging
28+
from typing import TYPE_CHECKING
29+
30+
from eventkit.schema.raw import RawEvent
31+
32+
if TYPE_CHECKING:
33+
from eventkit.processing.processor import Processor
34+
35+
logger = logging.getLogger(__name__)
36+
37+
38+
class AsyncQueue:
39+
"""
40+
In-process queue with background workers.
41+
42+
Events are added to an asyncio.Queue, then processed by N worker tasks
43+
running in the background. This decouples API request handling from
44+
event processing.
45+
46+
Example:
47+
>>> from eventkit.processing.processor import Processor
48+
>>> from eventkit.queues.async_queue import AsyncQueue
49+
>>>
50+
>>> processor = Processor(adapter, sequencer, buffer, error_store)
51+
>>> queue = AsyncQueue(processor, num_workers=4)
52+
>>>
53+
>>> await queue.start()
54+
>>> await queue.enqueue(raw_event) # Returns immediately
55+
>>> # Workers process in background
56+
>>> await queue.stop() # Drains queue before stopping
57+
"""
58+
59+
def __init__(self, processor: "Processor", num_workers: int = 4):
60+
"""
61+
Initialize AsyncQueue.
62+
63+
Args:
64+
processor: The processor that will handle events
65+
num_workers: Number of background worker tasks (default: 4)
66+
"""
67+
self.processor = processor
68+
self.num_workers = num_workers
69+
self.queue: asyncio.Queue[RawEvent] = asyncio.Queue()
70+
self.workers: list[asyncio.Task[None]] = []
71+
self._stop_event = asyncio.Event()
72+
73+
async def enqueue(self, event: RawEvent) -> None:
74+
"""
75+
Add event to queue (non-blocking).
76+
77+
Returns immediately after adding to queue. Background workers
78+
will process the event asynchronously.
79+
80+
Args:
81+
event: The raw event to process
82+
"""
83+
await self.queue.put(event)
84+
logger.debug(f"Event enqueued: queue_size={self.queue.qsize()}")
85+
86+
async def start(self) -> None:
87+
"""
88+
Start background workers and processor.
89+
90+
Creates N worker tasks that continuously pull from the queue
91+
and process events.
92+
"""
93+
# Start processor (starts buffer flusher)
94+
await self.processor.start()
95+
96+
# Start worker tasks
97+
self._stop_event.clear()
98+
for i in range(self.num_workers):
99+
worker = asyncio.create_task(self._worker(worker_id=i))
100+
self.workers.append(worker)
101+
102+
logger.info(f"AsyncQueue started with {self.num_workers} workers")
103+
104+
async def stop(self) -> None:
105+
"""
106+
Gracefully shutdown: drain queue, stop workers, stop processor.
107+
108+
1. Wait for queue to drain (process remaining events)
109+
2. Signal workers to stop
110+
3. Wait for workers to finish
111+
4. Stop processor (flush buffers)
112+
"""
113+
logger.info("AsyncQueue stopping - draining queue")
114+
115+
# Wait for queue to drain
116+
await self.queue.join()
117+
118+
# Signal workers to stop
119+
self._stop_event.set()
120+
121+
# Wait for all workers to finish
122+
await asyncio.gather(*self.workers, return_exceptions=True)
123+
self.workers.clear()
124+
125+
# Stop processor (flush buffers)
126+
await self.processor.stop()
127+
128+
logger.info("AsyncQueue stopped")
129+
130+
async def _worker(self, worker_id: int) -> None:
131+
"""
132+
Background worker that processes events from queue.
133+
134+
Continuously pulls events from queue and calls processor.process_event()
135+
until stop signal is received.
136+
137+
Args:
138+
worker_id: ID for logging purposes
139+
"""
140+
logger.debug(f"Worker {worker_id} started")
141+
142+
while not self._stop_event.is_set():
143+
try:
144+
# Wait for event with timeout (check stop signal periodically)
145+
event = await asyncio.wait_for(self.queue.get(), timeout=0.1)
146+
147+
# Process event
148+
try:
149+
await self.processor.process_event(event)
150+
except Exception as e:
151+
logger.error(f"Worker {worker_id} error processing event: {e}")
152+
finally:
153+
# Mark task as done (for queue.join())
154+
self.queue.task_done()
155+
156+
except TimeoutError:
157+
# No event available, check stop signal and continue
158+
continue
159+
except Exception as e:
160+
logger.error(f"Worker {worker_id} unexpected error: {e}")
161+
162+
logger.debug(f"Worker {worker_id} stopped")

‎src/eventkit/queues/base.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ class EventQueue(Protocol):
2323
All queue backends must implement this interface to be pluggable.
2424
2525
Implementations:
26-
- DirectQueue: Inline processing (no actual queue) - v0.1.0
27-
- AsyncQueue: In-process workers with asyncio.Queue - future
28-
- PubSubQueue: Distributed workers with GCP Pub/Sub - future
26+
- DirectQueue: Inline processing (no actual queue)
27+
- AsyncQueue: In-process workers with asyncio.Queue
28+
- PubSubQueue: Distributed workers with GCP Pub/Sub (future)
2929
3030
Example:
3131
>>> # Queue is created by factory based on config

‎src/eventkit/queues/direct.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
DirectQueue - Inline event processing (no actual queue).
33
4-
This is the simplest queue implementation for v0.1.0. Events are processed
4+
This is the simplest queue implementation. Events are processed
55
immediately when enqueue() is called, with no queueing or background workers.
66
77
Use Case:
@@ -16,8 +16,9 @@
1616
- ❌ No horizontal scaling: Can't add more workers
1717
1818
Evolution Path:
19-
- v0.1.0: Use DirectQueue (this file)
20-
- v0.2.0+: Swap to AsyncQueue or PubSubQueue via config
19+
- DirectQueue: Inline processing (this file)
20+
- AsyncQueue: In-process workers with background tasks
21+
- PubSubQueue: Distributed workers via GCP Pub/Sub (future)
2122
"""
2223

2324
from typing import TYPE_CHECKING

‎src/eventkit/queues/factory.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
(EVENTKIT_QUEUE_MODE) without changing application code.
66
77
Supported Modes:
8-
- direct: DirectQueue (inline processing) - v0.1.0 default
9-
- async: AsyncQueue (in-process workers) - future
8+
- direct: DirectQueue (inline processing) - default
9+
- async: AsyncQueue (in-process workers)
1010
- pubsub: PubSubQueue (distributed workers) - future
1111
"""
1212

@@ -55,10 +55,9 @@ def create_queue(processor: "Processor", settings: Settings) -> EventQueue:
5555
return DirectQueue(processor)
5656

5757
elif settings.EVENTKIT_QUEUE_MODE == QueueMode.ASYNC:
58-
# Future: AsyncQueue implementation
59-
raise NotImplementedError(
60-
"AsyncQueue not yet implemented. Set EVENTKIT_QUEUE_MODE=direct to use DirectQueue."
61-
)
58+
from eventkit.queues.async_queue import AsyncQueue
59+
60+
return AsyncQueue(processor, num_workers=settings.EVENTKIT_ASYNC_WORKERS)
6261

6362
elif settings.EVENTKIT_QUEUE_MODE == QueueMode.PUBSUB:
6463
# Future: PubSubQueue implementation

0 commit comments

Comments
 (0)