Skip to content

Commit a4272d0

Browse files
committed
feat: Add async event processor
1 parent 0587a78 commit a4272d0

4 files changed

Lines changed: 833 additions & 102 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""
2+
Implementation details of the analytics event delivery component.
3+
"""
4+
5+
import asyncio
6+
import gzip
7+
import json
8+
import queue
9+
import time
10+
import uuid
11+
from collections import namedtuple
12+
from random import Random
13+
from typing import Callable, Optional, Union
14+
15+
from ldclient.async_config import AsyncConfig
16+
from ldclient.impl.aio.concurrency import (
17+
AsyncEvent,
18+
AsyncLock,
19+
AsyncQueue,
20+
AsyncRepeatingTask,
21+
AsyncTaskRunner,
22+
AsyncWorkerPool
23+
)
24+
from ldclient.impl.aio.transport import AsyncHTTPTransport
25+
from ldclient.impl.events.diagnostics import create_diagnostic_init
26+
from ldclient.impl.events.event_processor_common import (
27+
EventBuffer,
28+
EventDispatcherBase,
29+
EventOutputFormatter
30+
)
31+
from ldclient.impl.events.types import EventInput
32+
from ldclient.impl.lru_cache import SimpleLRUCache
33+
from ldclient.impl.sampler import Sampler
34+
from ldclient.impl.util import (
35+
_headers,
36+
check_if_error_is_recoverable_and_log,
37+
log
38+
)
39+
from ldclient.interfaces import EventProcessor
40+
41+
__MAX_FLUSH_THREADS__ = 5
42+
__CURRENT_EVENT_SCHEMA__ = 4
43+
44+
45+
EventProcessorMessage = namedtuple('EventProcessorMessage', ['type', 'param'])
46+
47+
48+
class EventPayloadSendTask:
49+
def __init__(self, http: AsyncHTTPTransport, config: AsyncConfig, formatter: EventOutputFormatter, payload, response_fn: Callable):
50+
self._http = http
51+
self._config = config
52+
self._formatter = formatter
53+
self._payload = payload
54+
self._response_fn = response_fn
55+
56+
async def run(self):
57+
try:
58+
output_events = self._formatter.make_output_events(self._payload.events, self._payload.summary)
59+
await self._do_send(output_events)
60+
except Exception:
61+
log.warning('Unhandled exception in event processor. Analytics events were not processed.', exc_info=True)
62+
63+
async def _do_send(self, output_events):
64+
# noinspection PyBroadException
65+
try:
66+
json_body = json.dumps(output_events, separators=(',', ':'))
67+
log.debug('Sending events payload: ' + json_body)
68+
payload_id = str(uuid.uuid4())
69+
r = await _post_events_with_retry(self._http, self._config, self._config.events_uri, payload_id, json_body, "%d events" % len(output_events))
70+
if r:
71+
self._response_fn(r)
72+
return r
73+
except Exception as e:
74+
log.warning('Unhandled exception in event processor. Analytics events were not processed. [%s]', e)
75+
76+
77+
class DiagnosticEventSendTask:
78+
def __init__(self, http: AsyncHTTPTransport, config: AsyncConfig, event_body: dict):
79+
self._http = http
80+
self._config = config
81+
self._event_body = event_body
82+
83+
async def run(self):
84+
# noinspection PyBroadException
85+
try:
86+
json_body = json.dumps(self._event_body)
87+
log.debug('Sending diagnostic event: ' + json_body)
88+
await _post_events_with_retry(self._http, self._config, self._config.events_base_uri + '/diagnostic', None, json_body, "diagnostic event")
89+
except Exception as e:
90+
log.warning('Unhandled exception in event processor. Diagnostic event was not sent. [%s]', e)
91+
92+
93+
class EventDispatcher(EventDispatcherBase):
94+
def __init__(self, inbox: AsyncQueue, config: AsyncConfig, http_client, diagnostic_accumulator=None):
95+
self._inbox = inbox
96+
self._config = config
97+
# When no client is injected, the transport creates one targeting the
98+
# events URI and owns it (closing it on shutdown); an injected client
99+
# remains owned by the caller.
100+
self._http = AsyncHTTPTransport(config, client=http_client)
101+
self._disabled = False
102+
self._outbox = EventBuffer(config.events_max_pending)
103+
self._context_keys = SimpleLRUCache(config.context_keys_capacity)
104+
self._formatter = EventOutputFormatter(config)
105+
self._last_known_past_time = 0
106+
self._deduplicated_contexts = 0
107+
self._diagnostic_accumulator = None if config.diagnostic_opt_out else diagnostic_accumulator
108+
self._sampler = Sampler(Random())
109+
self._omit_anonymous_contexts = config.omit_anonymous_contexts
110+
111+
self._flush_workers = AsyncWorkerPool(__MAX_FLUSH_THREADS__, "ldclient.flush")
112+
self._diagnostic_flush_workers: Optional[AsyncWorkerPool] = None
113+
if self._diagnostic_accumulator is not None:
114+
self._diagnostic_flush_workers = AsyncWorkerPool(1, "ldclient.events.diag_flush")
115+
init_event = create_diagnostic_init(self._diagnostic_accumulator.data_since_date, self._diagnostic_accumulator.diagnostic_id, config)
116+
task = DiagnosticEventSendTask(self._http, self._config, init_event)
117+
self._diagnostic_flush_workers.execute(task.run)
118+
119+
self._runner = AsyncTaskRunner()
120+
self._runner.spawn("ldclient.events.processor", self._run_main_loop)
121+
122+
async def _run_main_loop(self):
123+
log.info("Starting event processor")
124+
while True:
125+
try:
126+
message = await self._inbox.get()
127+
if message.type == 'event':
128+
self._process_event(message.param)
129+
elif message.type == 'flush':
130+
self._trigger_flush()
131+
elif message.type == 'flush_contexts':
132+
self._context_keys.clear()
133+
elif message.type == 'diagnostic':
134+
self._send_and_reset_diagnostics()
135+
elif message.type == 'test_sync':
136+
await self._flush_workers.wait()
137+
if self._diagnostic_flush_workers is not None:
138+
await self._diagnostic_flush_workers.wait()
139+
message.param.set()
140+
elif message.type == 'stop':
141+
await self._do_shutdown()
142+
message.param.set()
143+
return
144+
except Exception:
145+
log.error('Unhandled exception in event processor', exc_info=True)
146+
147+
def _trigger_flush(self):
148+
if self._disabled:
149+
return
150+
payload = self._outbox.get_payload()
151+
if self._diagnostic_accumulator:
152+
self._diagnostic_accumulator.record_events_in_batch(len(payload.events))
153+
if len(payload.events) > 0 or not payload.summary.is_empty():
154+
task = EventPayloadSendTask(self._http, self._config, self._formatter, payload, self._handle_response)
155+
if self._flush_workers.execute(task.run):
156+
# The events have been handed off to a flush worker; clear them from our buffer.
157+
self._outbox.clear()
158+
else:
159+
# We're already at our limit of concurrent flushes; leave the events in the buffer.
160+
pass
161+
162+
def _send_and_reset_diagnostics(self):
163+
if self._diagnostic_accumulator is not None and self._diagnostic_flush_workers is not None:
164+
dropped_event_count = self._outbox.get_and_clear_dropped_count()
165+
stats_event = self._diagnostic_accumulator.create_event_and_reset(dropped_event_count, self._deduplicated_contexts)
166+
self._deduplicated_contexts = 0
167+
task = DiagnosticEventSendTask(self._http, self._config, stats_event)
168+
self._diagnostic_flush_workers.execute(task.run)
169+
170+
async def _do_shutdown(self):
171+
self._flush_workers.stop()
172+
await self._flush_workers.wait()
173+
174+
if self._diagnostic_flush_workers is not None:
175+
self._diagnostic_flush_workers.stop()
176+
await self._diagnostic_flush_workers.wait()
177+
178+
await self._http.close()
179+
180+
181+
class AsyncEventProcessor(EventProcessor):
182+
def __init__(self, config: AsyncConfig, http=None, dispatcher_class=None, diagnostic_accumulator=None):
183+
self._inbox = AsyncQueue(config.events_max_pending)
184+
self._inbox_full = False
185+
self._flush_timer = AsyncRepeatingTask("ldclient.events.flush", config.flush_interval, config.flush_interval, self.flush)
186+
self._contexts_flush_timer = AsyncRepeatingTask("ldclient.events.context-flush", config.context_keys_flush_interval, config.context_keys_flush_interval, self._flush_contexts)
187+
self._flush_timer.start()
188+
self._contexts_flush_timer.start()
189+
self._diagnostic_event_timer: Optional[AsyncRepeatingTask]
190+
if diagnostic_accumulator is not None:
191+
self._diagnostic_event_timer = AsyncRepeatingTask("ldclient.events.send-diagnostic", config.diagnostic_recording_interval, config.diagnostic_recording_interval, self._send_diagnostic)
192+
self._diagnostic_event_timer.start()
193+
else:
194+
self._diagnostic_event_timer = None
195+
196+
self._close_lock = AsyncLock()
197+
self._closed = False
198+
199+
(dispatcher_class or EventDispatcher)(self._inbox, config, http, diagnostic_accumulator)
200+
201+
def send_event(self, event: EventInput):
202+
self._post_to_inbox(EventProcessorMessage('event', event))
203+
204+
def flush(self):
205+
self._post_to_inbox(EventProcessorMessage('flush', None))
206+
207+
async def stop(self):
208+
async with self._close_lock:
209+
if self._closed:
210+
return
211+
self._closed = True
212+
self._flush_timer.stop()
213+
self._contexts_flush_timer.stop()
214+
if self._diagnostic_event_timer:
215+
self._diagnostic_event_timer.stop()
216+
self.flush()
217+
# Note that here we are not calling _post_to_inbox, because we *do* want to wait if the inbox
218+
# is full; an orderly shutdown can't happen unless these messages are received.
219+
await self._post_message_and_wait('stop')
220+
221+
def _post_to_inbox(self, message: EventProcessorMessage):
222+
try:
223+
self._inbox.put_nowait(message)
224+
except queue.Full:
225+
if not self._inbox_full:
226+
# possible race condition here, but it's of no real consequence - we'd just get an extra log line
227+
self._inbox_full = True
228+
log.warning("Events are being produced faster than they can be processed; some events will be dropped")
229+
230+
async def _flush_contexts(self):
231+
await self._inbox.put(EventProcessorMessage('flush_contexts', None))
232+
233+
async def _send_diagnostic(self):
234+
await self._inbox.put(EventProcessorMessage('diagnostic', None))
235+
236+
# Used only in tests
237+
async def _wait_until_inactive(self):
238+
await self._post_message_and_wait('test_sync')
239+
240+
async def _post_message_and_wait(self, type):
241+
reply = AsyncEvent()
242+
await self._inbox.put(EventProcessorMessage(type, reply))
243+
await reply.wait()
244+
245+
# These magic methods allow use of the "with" block in tests
246+
async def __aenter__(self):
247+
return self
248+
249+
async def __aexit__(self, type, value, traceback):
250+
await self.stop()
251+
252+
253+
async def _post_events_with_retry(http_client: AsyncHTTPTransport, config: AsyncConfig, uri: str, payload_id: Optional[str], body: str, events_description: str):
254+
hdrs = _headers(config)
255+
hdrs['Content-Type'] = 'application/json'
256+
if config.enable_event_compression:
257+
hdrs['Content-Encoding'] = 'gzip'
258+
259+
if payload_id:
260+
hdrs['X-LaunchDarkly-Event-Schema'] = str(__CURRENT_EVENT_SCHEMA__)
261+
hdrs['X-LaunchDarkly-Payload-ID'] = payload_id
262+
can_retry = True
263+
context = "posting %s" % events_description
264+
data: Union[bytes, str] = gzip.compress(bytes(body, 'utf-8')) if config.enable_event_compression else body
265+
while True:
266+
next_action_message = "will retry" if can_retry else "some events were dropped"
267+
try:
268+
r = await http_client.request('POST', uri, headers=hdrs, body=data)
269+
if r.status < 300:
270+
return r
271+
recoverable = check_if_error_is_recoverable_and_log(context, r.status, None, next_action_message)
272+
if not recoverable:
273+
return r
274+
except Exception as e:
275+
check_if_error_is_recoverable_and_log(context, None, str(e), next_action_message)
276+
if not can_retry:
277+
return None
278+
can_retry = False
279+
# fixed delay of 1 second for event retries
280+
await asyncio.sleep(1)

0 commit comments

Comments
 (0)