4.44.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 2m53s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m28s
Build-Release-Image / Merge-Images (push) Successful in 26s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 3s

This commit is contained in:
MrMeeb 2024-05-24 12:00:06 +01:00
parent b0a170dcb4
commit f447611d6f
8 changed files with 109 additions and 15 deletions

View File

@ -583,3 +583,7 @@ UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ
EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None) EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
# We want it disabled by default, so only skip if defined
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ

View File

@ -34,6 +34,9 @@ class EventDispatcher:
dispatcher: Dispatcher = PostgresDispatcher.get(), dispatcher: Dispatcher = PostgresDispatcher.get(),
skip_if_webhook_missing: bool = True, skip_if_webhook_missing: bool = True,
): ):
if config.EVENT_WEBHOOK_DISABLE:
return
if not config.EVENT_WEBHOOK and skip_if_webhook_missing: if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
return return

View File

@ -3699,7 +3699,10 @@ class SyncEvent(Base, ModelMixin):
AND taken_time IS NULL AND taken_time IS NULL
""" """
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
res = Session.execute(sql, args) res = Session.execute(sql, args)
Session.commit()
return res.rowcount > 0 return res.rowcount > 0
@classmethod @classmethod

View File

@ -3,9 +3,10 @@ from enum import Enum
from sys import argv, exit from sys import argv, exit
from app.config import DB_URI from app.config import DB_URI
from app.log import LOG
from events.runner import Runner from events.runner import Runner
from events.event_source import DeadLetterEventSource, PostgresEventSource from events.event_source import DeadLetterEventSource, PostgresEventSource
from events.event_sink import ConsoleEventSink from events.event_sink import ConsoleEventSink, HttpEventSink
class Mode(Enum): class Mode(Enum):
@ -24,16 +25,20 @@ class Mode(Enum):
def main(mode: Mode, dry_run: bool): def main(mode: Mode, dry_run: bool):
if mode == Mode.DEAD_LETTER: if mode == Mode.DEAD_LETTER:
LOG.i("Using DeadLetterEventSource")
source = DeadLetterEventSource() source = DeadLetterEventSource()
elif mode == Mode.LISTENER: elif mode == Mode.LISTENER:
LOG.i("Using PostgresEventSource")
source = PostgresEventSource(DB_URI) source = PostgresEventSource(DB_URI)
else: else:
raise ValueError(f"Invalid mode: {mode}") raise ValueError(f"Invalid mode: {mode}")
if dry_run: if dry_run:
LOG.i("Starting with ConsoleEventSink")
sink = ConsoleEventSink() sink = ConsoleEventSink()
else: else:
sink = ConsoleEventSink() LOG.i("Starting with HttpEventSink")
sink = HttpEventSink()
runner = Runner(source=source, sink=sink) runner = Runner(source=source, sink=sink)
runner.run() runner.run()

View File

@ -1,19 +1,42 @@
import requests
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
from app.log import LOG from app.log import LOG
from app.models import SyncEvent from app.models import SyncEvent
class EventSink(ABC): class EventSink(ABC):
@abstractmethod @abstractmethod
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass pass
class HttpEventSink(EventSink): class HttpEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass if not EVENT_WEBHOOK:
LOG.warning("Skipping sending event because there is no webhook configured")
return False
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
res = requests.post(
url=EVENT_WEBHOOK,
data=event.content,
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
if res.status_code != 200:
LOG.warning(
f"Failed to send event to webhook: {res.status_code} {res.text}"
)
return False
else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
class ConsoleEventSink(EventSink): class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}") LOG.info(f"Handling event {event.id}")
return True

View File

@ -13,6 +13,8 @@ from typing import Callable, NoReturn
_DEAD_LETTER_THRESHOLD_MINUTES = 10 _DEAD_LETTER_THRESHOLD_MINUTES = 10
_DEAD_LETTER_INTERVAL_SECONDS = 30 _DEAD_LETTER_INTERVAL_SECONDS = 30
_POSTGRES_RECONNECT_INTERVAL_SECONDS = 5
class EventSource(ABC): class EventSource(ABC):
@abstractmethod @abstractmethod
@ -22,9 +24,19 @@ class EventSource(ABC):
class PostgresEventSource(EventSource): class PostgresEventSource(EventSource):
def __init__(self, connection_string: str): def __init__(self, connection_string: str):
self.__connection = psycopg2.connect(connection_string) self.__connection_string = connection_string
self.__connect()
def run(self, on_event: Callable[[SyncEvent], NoReturn]): def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True:
try:
self.__listen(on_event)
except Exception as e:
LOG.warn(f"Error listening to events: {e}")
sleep(_POSTGRES_RECONNECT_INTERVAL_SECONDS)
self.__connect()
def __listen(self, on_event: Callable[[SyncEvent], NoReturn]):
self.__connection.set_isolation_level( self.__connection.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
) )
@ -44,12 +56,24 @@ class PostgresEventSource(EventSource):
webhook_id = int(notify.payload) webhook_id = int(notify.payload)
event = SyncEvent.get_by(id=webhook_id) event = SyncEvent.get_by(id=webhook_id)
if event is not None: if event is not None:
on_event(event) if event.mark_as_taken():
on_event(event)
else:
LOG.info(
f"Event {event.id} was handled by another runner"
)
else: else:
LOG.info(f"Could not find event with id={notify.payload}") LOG.info(f"Could not find event with id={notify.payload}")
except Exception as e: except Exception as e:
LOG.warn(f"Error getting event: {e}") LOG.warn(f"Error getting event: {e}")
def __connect(self):
self.__connection = psycopg2.connect(self.__connection_string)
from app.db import Session
Session.close()
class DeadLetterEventSource(EventSource): class DeadLetterEventSource(EventSource):
@newrelic.agent.background_task() @newrelic.agent.background_task()
@ -73,3 +97,4 @@ class DeadLetterEventSource(EventSource):
sleep(_DEAD_LETTER_INTERVAL_SECONDS) sleep(_DEAD_LETTER_INTERVAL_SECONDS)
except Exception as e: except Exception as e:
LOG.warn(f"Error getting dead letter event: {e}") LOG.warn(f"Error getting dead letter event: {e}")
sleep(_DEAD_LETTER_INTERVAL_SECONDS)

View File

@ -18,11 +18,10 @@ class Runner:
@newrelic.agent.background_task() @newrelic.agent.background_task()
def __on_event(self, event: SyncEvent): def __on_event(self, event: SyncEvent):
try: try:
can_process = event.mark_as_taken() event_created_at = event.created_at
if can_process: start_time = arrow.now()
event_created_at = event.created_at success = self.__sink.process(event)
start_time = arrow.now() if success:
self.__sink.process(event)
event_id = event.id event_id = event.id
SyncEvent.delete(event.id, commit=True) SyncEvent.delete(event.id, commit=True)
LOG.info(f"Marked {event_id} as done") LOG.info(f"Marked {event_id} as done")
@ -38,8 +37,6 @@ class Runner:
"Custom/sync_event_elapsed_time", "Custom/sync_event_elapsed_time",
time_between_taken_and_created.total_seconds(), time_between_taken_and_created.total_seconds(),
) )
else:
LOG.info(f"{event.id} was handled by another runner")
except Exception as e: except Exception as e:
LOG.warn(f"Exception processing event [id={event.id}]: {e}") LOG.warn(f"Exception processing event [id={event.id}]: {e}")
newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1) newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1)

View File

@ -4,6 +4,7 @@ import subprocess
from time import sleep from time import sleep
from typing import List, Dict from typing import List, Dict
import arrow
import newrelic.agent import newrelic.agent
from app.db import Session from app.db import Session
@ -93,11 +94,44 @@ def log_nb_db_connection():
newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection) newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection)
@newrelic.agent.background_task()
def log_pending_to_process_events():
r = Session.execute("select count(*) from sync_event WHERE taken_time IS NULL;")
events_pending = list(r)[0][0]
LOG.d("number of events pending to process %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_to_process", events_pending
)
@newrelic.agent.background_task()
def log_events_pending_dead_letter():
since = arrow.now().shift(minutes=-10).datetime
r = Session.execute(
"""
SELECT COUNT(*)
FROM sync_event
WHERE (taken_time IS NOT NULL AND taken_time < :since)
OR (taken_time IS NULL AND created_at < :since)
""",
{"since": since},
)
events_pending = list(r)[0][0]
LOG.d("number of events pending dead letter %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_dead_letter", events_pending
)
if __name__ == "__main__": if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license()) exporter = MetricExporter(get_newrelic_license())
while True: while True:
log_postfix_metrics() log_postfix_metrics()
log_nb_db_connection() log_nb_db_connection()
log_pending_to_process_events()
log_events_pending_dead_letter()
Session.close() Session.close()
exporter.run() exporter.run()