Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/events/services/event_persister.py: 93%
113 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 02:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 02:04 +0000
1"""
2The event persister moves event messages from the event bus to storage
3storage as fast as it can. Never gets tired.
4"""
6from __future__ import annotations
8import asyncio
9from contextlib import asynccontextmanager
10from datetime import timedelta
11from typing import TYPE_CHECKING, AsyncGenerator, List, NoReturn
13from prefect.logging import get_logger
14from prefect.server.database import provide_database_interface
15from prefect.server.events.schemas.events import ReceivedEvent
16from prefect.server.events.storage.database import write_events
17from prefect.server.services.base import RunInEphemeralServers, Service
18from prefect.server.utilities.messaging import (
19 Consumer,
20 Message,
21 MessageHandler,
22 create_consumer,
23)
24from prefect.server.utilities.messaging._consumer_names import (
25 generate_unique_consumer_name,
26)
27from prefect.settings.context import get_current_settings
28from prefect.settings.models.server.services import ServerServicesEventPersisterSettings
30if TYPE_CHECKING: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 import logging
33logger: "logging.Logger" = get_logger(__name__)
36class EventPersister(RunInEphemeralServers, Service):
37 """A service that persists events to the database as they arrive."""
39 consumer_task: asyncio.Task[None] | None = None
41 @classmethod
42 def service_settings(cls) -> ServerServicesEventPersisterSettings:
43 return get_current_settings().server.services.event_persister
45 def __init__(self):
46 super().__init__()
47 self._started_event: asyncio.Event | None = None
49 @property
50 def started_event(self) -> asyncio.Event:
51 if self._started_event is None:
52 self._started_event = asyncio.Event()
53 return self._started_event
55 @started_event.setter
56 def started_event(self, value: asyncio.Event) -> None:
57 self._started_event = value
59 async def start(self) -> NoReturn:
60 assert self.consumer_task is None, "Event persister already started"
61 self.consumer: Consumer = create_consumer(
62 "events",
63 group="event-persister",
64 name=generate_unique_consumer_name("event-persister"),
65 read_batch_size=self.service_settings().read_batch_size,
66 )
68 settings = self.service_settings()
69 async with create_handler(
70 batch_size=settings.batch_size,
71 flush_every=timedelta(seconds=settings.flush_interval),
72 queue_max_size=settings.queue_max_size,
73 max_flush_retries=settings.max_flush_retries,
74 ) as handler:
75 self.consumer_task = asyncio.create_task(self.consumer.run(handler))
76 logger.debug("Event persister started")
77 self.started_event.set()
79 try:
80 await self.consumer_task
81 except asyncio.CancelledError:
82 pass
84 async def stop(self) -> None:
85 assert self.consumer_task is not None, "Event persister not started"
86 self.consumer_task.cancel()
87 try:
88 await self.consumer_task
89 except asyncio.CancelledError:
90 pass
91 finally:
92 await self.consumer.cleanup()
93 self.consumer_task = None
94 if self.started_event: 94 ↛ 96line 94 didn't jump to line 96 because the condition on line 94 was always true
95 self.started_event.clear()
96 logger.debug("Event persister stopped")
99@asynccontextmanager
100async def create_handler(
101 batch_size: int = 20,
102 flush_every: timedelta = timedelta(seconds=5),
103 queue_max_size: int = 50_000,
104 max_flush_retries: int = 5,
105) -> AsyncGenerator[MessageHandler, None]:
106 """
107 Set up a message handler that will accumulate and send events to
108 the database every `batch_size` messages, or every `flush_every` interval to flush
109 any remaining messages.
111 Event trimming/retention is handled by the db_vacuum service
112 (vacuum_old_events and vacuum_events_with_retention_overrides tasks).
114 Args:
115 batch_size: Number of events to accumulate before flushing
116 flush_every: Maximum time between flushes
117 queue_max_size: Maximum events in queue before dropping new events
118 max_flush_retries: Consecutive flush failures before dropping events
119 """
120 db = provide_database_interface()
122 queue: asyncio.Queue[ReceivedEvent] = asyncio.Queue(maxsize=queue_max_size)
123 flush_lock = asyncio.Lock()
124 consecutive_failures = 0
126 async def flush() -> None:
127 nonlocal consecutive_failures
129 async with flush_lock:
130 if queue.qsize() == 0: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 return
133 # Log warning when queue reaches 80% capacity
134 if queue_max_size > 0 and queue.qsize() > queue_max_size * 0.8: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 logger.warning(
136 "Event queue at %d%% capacity (%d/%d)",
137 int(queue.qsize() / queue_max_size * 100),
138 queue.qsize(),
139 queue_max_size,
140 )
142 logger.debug("Persisting %d events...", queue.qsize())
144 batch: List[ReceivedEvent] = []
146 while queue.qsize() > 0:
147 batch.append(await queue.get())
149 try:
150 async with db.session_context() as session:
151 await write_events(session=session, events=batch)
152 await session.commit()
153 logger.debug("Finished persisting events.")
154 consecutive_failures = 0 # Reset on success
155 except Exception:
156 consecutive_failures += 1
157 if consecutive_failures >= max_flush_retries:
158 logger.error(
159 "Max flush retries (%d) reached, dropping %d events",
160 max_flush_retries,
161 len(batch),
162 exc_info=True,
163 )
164 consecutive_failures = 0
165 else:
166 logger.debug(
167 "Error flushing events (attempt %d/%d), restoring to queue",
168 consecutive_failures,
169 max_flush_retries,
170 exc_info=True,
171 )
172 for event in batch:
173 queue.put_nowait(event)
175 async def flush_periodically():
176 try:
177 while True:
178 await asyncio.sleep(flush_every.total_seconds())
179 if queue.qsize():
180 await flush()
181 except asyncio.CancelledError:
182 return
184 async def message_handler(message: Message):
185 if not message.data:
186 return
188 event = ReceivedEvent.model_validate_json(message.data)
190 logger.debug(
191 "Received event: %s with id: %s for resource: %s",
192 event.event,
193 event.id,
194 event.resource.get("prefect.resource.id"),
195 )
197 try:
198 queue.put_nowait(event)
199 except asyncio.QueueFull:
200 logger.warning(
201 "Event queue full (%d/%d), dropping event id=%s",
202 queue.qsize(),
203 queue_max_size,
204 event.id,
205 )
206 return
208 if queue.qsize() >= batch_size:
209 await flush()
211 periodic_flush = asyncio.create_task(flush_periodically())
213 try:
214 yield message_handler
215 finally:
216 periodic_flush.cancel()
217 if queue.qsize(): 217 ↛ exitline 217 didn't return from function 'create_handler' because the condition on line 217 was always true
218 await flush()