476 lines
18 KiB
Python
476 lines
18 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
#
|
||
|
# This file is part of the FMN project.
|
||
|
# Copyright (C) 2017 Red Hat, Inc.
|
||
|
#
|
||
|
# This library is free software; you can redistribute it and/or
|
||
|
# modify it under the terms of the GNU Lesser General Public
|
||
|
# License as published by the Free Software Foundation; either
|
||
|
# version 2.1 of the License, or (at your option) any later version.
|
||
|
#
|
||
|
# This library is distributed in the hope that it will be useful,
|
||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||
|
# Lesser General Public License for more details.
|
||
|
#
|
||
|
# You should have received a copy of the GNU Lesser General Public
|
||
|
# License along with this library; if not, write to the Free Software
|
||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||
|
|
||
|
"""
|
||
|
This module contains the `Celery tasks`_ used by FMN.
|
||
|
|
||
|
.. _Celery tasks: http://docs.celeryproject.org/en/latest/
|
||
|
"""
|
||
|
|
||
|
from __future__ import absolute_import
|
||
|
|
||
|
import datetime
|
||
|
|
||
|
from celery.utils.log import get_task_logger
|
||
|
from fedmsg_meta_fedora_infrastructure import fasshim
|
||
|
from kombu import Connection, Queue
|
||
|
from kombu.pools import connections
|
||
|
from celery import task
|
||
|
import fedmsg
|
||
|
import fedmsg.meta
|
||
|
import fedmsg_meta_fedora_infrastructure
|
||
|
import sqlalchemy
|
||
|
|
||
|
from . import config, lib as fmn_lib, formatters, exceptions
|
||
|
from . import fmn_fasshim
|
||
|
from .lib import models
|
||
|
from .celery import app
|
||
|
from .constants import BACKEND_QUEUE_PREFIX
|
||
|
|
||
|
|
||
|
__all__ = ['find_recipients']
|
||
|
|
||
|
|
||
|
_log = get_task_logger(__name__)
|
||
|
|
||
|
|
||
|
REFRESH_CACHE_TOPIC = 'fmn.internal.refresh_cache'
|
||
|
|
||
|
|
||
|
# Monkey patch fedmsg_meta modules
|
||
|
fasshim.nick2fas = fmn_fasshim.nick2fas
|
||
|
fasshim.email2fas = fmn_fasshim.email2fas
|
||
|
fedmsg_meta_fedora_infrastructure.supybot.nick2fas = fmn_fasshim.nick2fas
|
||
|
fedmsg_meta_fedora_infrastructure.anitya.email2fas = fmn_fasshim.email2fas
|
||
|
fedmsg_meta_fedora_infrastructure.bz.email2fas = fmn_fasshim.email2fas
|
||
|
fedmsg_meta_fedora_infrastructure.mailman3.email2fas = fmn_fasshim.email2fas
|
||
|
fedmsg_meta_fedora_infrastructure.pagure.email2fas = fmn_fasshim.email2fas
|
||
|
|
||
|
|
||
|
class _FindRecipients(task.Task):
|
||
|
"""A Celery task sub-class that loads and caches user preferences."""
|
||
|
|
||
|
name = 'fmn.tasks.find_recipients'
|
||
|
# Retry tasks every hour for 60 days before giving up
|
||
|
default_retry_delay = 3600
|
||
|
max_retries = 1440
|
||
|
autoretry_for = (Exception,)
|
||
|
|
||
|
def __init__(self):
|
||
|
"""
|
||
|
Initialize caches and other resources for the tasks that require user preferences.
|
||
|
|
||
|
This is run once per process, not per task.
|
||
|
"""
|
||
|
_log.info('Initializing the "%s" task', self.name)
|
||
|
fedmsg.meta.make_processors(**config.app_conf)
|
||
|
self._valid_paths = None
|
||
|
self._user_preferences = None
|
||
|
_log.info('Initialization complete for the "%s" task', self.name)
|
||
|
|
||
|
@property
|
||
|
def valid_paths(self):
|
||
|
"""
|
||
|
A property that lazy-loads the valid paths for FMN rules.
|
||
|
|
||
|
This is done here rather in ``__init__`` so that users of this task
|
||
|
don't load all the valid paths when the task is registered with
|
||
|
Celery.
|
||
|
"""
|
||
|
if self._valid_paths is None:
|
||
|
_log.info('Loading valid FMN rule paths')
|
||
|
self._valid_paths = fmn_lib.load_rules(root="fmn.rules")
|
||
|
_log.info('All FMN rule paths successfully loaded')
|
||
|
return self._valid_paths
|
||
|
|
||
|
@property
|
||
|
def user_preferences(self):
|
||
|
"""
|
||
|
A property that lazy-loads the user preferences.
|
||
|
|
||
|
This is done here rather in ``__init__`` so that users of this task
|
||
|
don't load all the user preferences when the task is registered with
|
||
|
Celery.
|
||
|
"""
|
||
|
if self._user_preferences is None:
|
||
|
_log.info('Loading all user preferences from the database')
|
||
|
self._user_preferences = fmn_lib.load_preferences(
|
||
|
cull_disabled=True, cull_backends=['desktop'])
|
||
|
_log.info('All user preferences successfully loaded from the database')
|
||
|
return self._user_preferences
|
||
|
|
||
|
def run(self, message):
|
||
|
"""
|
||
|
A Celery task that finds a list of recipients for a message.
|
||
|
|
||
|
When the recipients have been found, it publishes an AMQP message for each
|
||
|
context (backend) in the format::
|
||
|
|
||
|
{
|
||
|
'context': <backend>,
|
||
|
'recipients': [
|
||
|
{
|
||
|
"triggered_by_links": true,
|
||
|
"markup_messages": false,
|
||
|
"user": "jcline.id.fedoraproject.org",
|
||
|
"filter_name": "firehose",
|
||
|
"filter_oneshot": false,
|
||
|
"filter_id": 7,
|
||
|
"shorten_links": false,
|
||
|
"verbose": true,
|
||
|
},
|
||
|
]
|
||
|
'raw_msg': the message that this task handled,
|
||
|
}
|
||
|
|
||
|
|
||
|
Args:
|
||
|
self (celery.Task): The instance of the Task object this function is bound to.
|
||
|
message (dict): A fedmsg to find recipients for.
|
||
|
"""
|
||
|
_log.debug('Determining recipients for message "%r"', message)
|
||
|
topic, message_body = message['topic'], message['body']
|
||
|
|
||
|
# We send a fake message with this topic as a broadcast to all workers in order for them
|
||
|
# to refresh their caches, so if this message is a cache refresh notification stop early.
|
||
|
if topic == REFRESH_CACHE_TOPIC:
|
||
|
_log.info('Refreshing the user preferences for %s', message_body)
|
||
|
fmn_lib.update_preferences(message_body, self.user_preferences)
|
||
|
return
|
||
|
|
||
|
results = fmn_lib.recipients(
|
||
|
self.user_preferences, message_body, self.valid_paths, config.app_conf)
|
||
|
_log.info('Found %s recipients for message %s', sum(map(len, results.values())),
|
||
|
message_body.get('msg_id', topic))
|
||
|
|
||
|
self._queue_for_delivery(results, message)
|
||
|
|
||
|
def _queue_for_delivery(self, results, message):
|
||
|
"""
|
||
|
Queue a processed message for delivery to its recipients.
|
||
|
|
||
|
The message is either delivered to the default AMQP exchange with the 'backends'
|
||
|
routing key or placed in the database if the user has enabled batch delivery. If
|
||
|
it is placed in the database, the :func:`batch_messages` task will handle its
|
||
|
delivery.
|
||
|
|
||
|
Message format::
|
||
|
{
|
||
|
"context": "email",
|
||
|
"recipient": dict,
|
||
|
"fedmsg": dict,
|
||
|
"formatted_message": <formatted_message>
|
||
|
}
|
||
|
|
||
|
Args:
|
||
|
results (dict): A dictionary where the keys are context names and the values are
|
||
|
a list of recipients for that context. A recipient entry in the list is a
|
||
|
dictionary. See :func:`fmn.lib.recipients` for the dictionary format.
|
||
|
message (dict): The raw fedmsg to humanize and deliver to the given recipients.
|
||
|
"""
|
||
|
broker_url = config.app_conf['celery']['broker']
|
||
|
|
||
|
with connections[Connection(broker_url)].acquire(block=True, timeout=60) as conn:
|
||
|
producer = conn.Producer()
|
||
|
for context, recipients in results.items():
|
||
|
_log.info('Dispatching messages for %d recipients for the %s backend',
|
||
|
len(recipients), context)
|
||
|
for recipient in recipients:
|
||
|
_maybe_mark_filter_fired(recipient)
|
||
|
|
||
|
user = recipient['user']
|
||
|
preference = self.user_preferences['{}_{}'.format(user, context)]
|
||
|
if _batch(preference, context, recipient, message):
|
||
|
continue
|
||
|
|
||
|
formatted_message = _format(context, message, recipient)
|
||
|
|
||
|
_log.info('Queuing message for delivery to %s on the %s backend', user, context)
|
||
|
backend_message = {
|
||
|
"context": context,
|
||
|
"recipient": recipient,
|
||
|
"fedmsg": message,
|
||
|
"formatted_message": formatted_message,
|
||
|
}
|
||
|
routing_key = BACKEND_QUEUE_PREFIX + context
|
||
|
producer.publish(backend_message, routing_key=routing_key,
|
||
|
declare=[Queue(routing_key, durable=True)])
|
||
|
|
||
|
|
||
|
def _maybe_mark_filter_fired(recipient):
|
||
|
"""
|
||
|
If the filter was a one-shot filter, try to mark it as triggered. If that fails,
|
||
|
log the error and continue since there's not much else to be done.
|
||
|
|
||
|
Args:
|
||
|
recipient (dict): The recipient dictionary.
|
||
|
"""
|
||
|
|
||
|
if ('filter_oneshot' in recipient and recipient['filter_oneshot']):
|
||
|
_log.info('Marking one-time filter as fired')
|
||
|
session = models.Session()
|
||
|
idx = recipient['filter_id']
|
||
|
try:
|
||
|
fltr = models.Filter.query.get(idx)
|
||
|
fltr.fired(session)
|
||
|
session.commit()
|
||
|
except (sqlalchemy.exc.SQLAlchemyError, AttributeError):
|
||
|
_log.exception('Unable to mark one-shot filter (id %s) as fired', idx)
|
||
|
session.rollback()
|
||
|
finally:
|
||
|
models.Session.remove()
|
||
|
|
||
|
|
||
|
def _batch(preference, context, recipient, message):
|
||
|
"""
|
||
|
Batch the message if the user wishes it.
|
||
|
|
||
|
Args:
|
||
|
preference (dict): The user's preferences in dictionary form.
|
||
|
context (str): The context to batch it for.
|
||
|
recipient (dict): The recipient dictionary.
|
||
|
message (dict): The fedmsg to batch.
|
||
|
"""
|
||
|
if preference.get('batch_delta') or preference.get('batch_count'):
|
||
|
_log.info('User "%s" has batch delivery set; placing message in database',
|
||
|
recipient['user'])
|
||
|
session = models.Session()
|
||
|
try:
|
||
|
models.QueuedMessage.enqueue(session, recipient['user'], context, message)
|
||
|
session.commit()
|
||
|
return True
|
||
|
except sqlalchemy.exc.SQLAlchemyError:
|
||
|
_log.exception('Unable to queue message for batch delivery')
|
||
|
session.rollback()
|
||
|
finally:
|
||
|
models.Session.remove()
|
||
|
|
||
|
return False
|
||
|
|
||
|
|
||
|
def _format(context, message, recipient):
|
||
|
"""
|
||
|
Format the message(s) using the context and recipient to determine settings.
|
||
|
|
||
|
Args:
|
||
|
context (str): The name of the context; this is used to determine what formatter
|
||
|
function to use.
|
||
|
message (dict or list): A fedmsg or list of fedmsgs to format.
|
||
|
recipient (dict): A recipient dictionary passed on to the formatter function.
|
||
|
|
||
|
Raises:
|
||
|
FmnError: If the message could not be formatted.
|
||
|
"""
|
||
|
formatted_message = None
|
||
|
|
||
|
# If it's a dictionary, it's a single message that doesn't need batching
|
||
|
if isinstance(message, dict):
|
||
|
if context == 'email':
|
||
|
formatted_message = formatters.email(message['body'], recipient)
|
||
|
elif context == 'irc':
|
||
|
formatted_message = formatters.irc(message['body'], recipient)
|
||
|
elif context == 'sse':
|
||
|
try:
|
||
|
formatted_message = formatters.sse(message['body'], recipient)
|
||
|
except Exception:
|
||
|
_log.exception('An exception occurred formatting the message '
|
||
|
'for delivery: falling back to sending the raw fedmsg')
|
||
|
formatted_message = message
|
||
|
elif isinstance(message, list):
|
||
|
if context == 'email':
|
||
|
formatted_message = formatters.email_batch(
|
||
|
[m['body'] for m in message], recipient)
|
||
|
elif context == 'irc':
|
||
|
formatted_message = formatters.irc_batch(
|
||
|
[m['body'] for m in message], recipient)
|
||
|
|
||
|
if formatted_message is None:
|
||
|
raise exceptions.FmnError(
|
||
|
'The message was not formatted in any way, aborting!')
|
||
|
|
||
|
return formatted_message
|
||
|
|
||
|
|
||
|
@app.task(name='fmn.tasks.batch_messages', ignore_results=True)
|
||
|
def batch_messages():
|
||
|
"""
|
||
|
A task that collects all messages ready for batch delivery and queues them.
|
||
|
|
||
|
Messages for users of the batch feature are placed in the database by the
|
||
|
:func:`find_recipients` task. Those messages are then picked up by this task,
|
||
|
turned into a summary using the :mod:`fmn.formatters` module, and placed in
|
||
|
the delivery service's AMQP queue.
|
||
|
|
||
|
This is intended to be run as a periodic task using Celery's beat service.
|
||
|
"""
|
||
|
session = models.Session()
|
||
|
try:
|
||
|
broker_url = config.app_conf['celery']['broker']
|
||
|
with connections[Connection(broker_url)].acquire(block=True, timeout=60) as conn:
|
||
|
producer = conn.Producer()
|
||
|
for pref in models.Preference.list_batching(session):
|
||
|
if not _batch_ready(pref):
|
||
|
continue
|
||
|
|
||
|
queued_messages = models.QueuedMessage.list_for(
|
||
|
session, pref.user, pref.context)
|
||
|
_log.info('Batching %d queued messages for %s',
|
||
|
len(queued_messages), pref.user.openid)
|
||
|
|
||
|
messages = [m.message for m in queued_messages]
|
||
|
recipients = [
|
||
|
{
|
||
|
pref.context.detail_name: value.value,
|
||
|
'user': pref.user.openid,
|
||
|
'markup_messages': pref.markup_messages,
|
||
|
'triggered_by_links': pref.triggered_by_links,
|
||
|
'shorten_links': pref.shorten_links,
|
||
|
}
|
||
|
for value in pref.detail_values
|
||
|
]
|
||
|
for recipient in recipients:
|
||
|
try:
|
||
|
formatted_message = _format(pref.context.name, messages, recipient)
|
||
|
except exceptions.FmnError:
|
||
|
_log.error('A batch message for %r was not formatted, skipping!',
|
||
|
recipient)
|
||
|
continue
|
||
|
|
||
|
backend_message = {
|
||
|
"context": pref.context.name,
|
||
|
"recipient": recipient,
|
||
|
"fedmsg": messages,
|
||
|
"formatted_message": formatted_message,
|
||
|
}
|
||
|
routing_key = BACKEND_QUEUE_PREFIX + pref.context.name
|
||
|
producer.publish(backend_message, routing_key=routing_key,
|
||
|
declare=[Queue(routing_key, durable=True)])
|
||
|
|
||
|
for message in queued_messages:
|
||
|
message.dequeue(session)
|
||
|
session.commit()
|
||
|
except sqlalchemy.exc.SQLAlchemyError:
|
||
|
_log.exception('Failed to dispatch queued messages for delivery')
|
||
|
session.rollback()
|
||
|
finally:
|
||
|
models.Session.remove()
|
||
|
|
||
|
|
||
|
def _batch_ready(preference):
|
||
|
"""
|
||
|
Determine if a message batch is ready for a user.
|
||
|
|
||
|
Args:
|
||
|
preference (models.Preference): The user preference entry which
|
||
|
contains the user's batch preferences.
|
||
|
Returns:
|
||
|
bool: True if there's a batch ready.
|
||
|
"""
|
||
|
session = models.Session()
|
||
|
try:
|
||
|
count = models.QueuedMessage.count_for(session, preference.user, preference.context)
|
||
|
if not count:
|
||
|
return False
|
||
|
|
||
|
# Batch based on count
|
||
|
if preference.batch_count is not None and preference.batch_count <= count:
|
||
|
_log.info("Sending digest for %r per msg count", preference.user.openid)
|
||
|
return True
|
||
|
|
||
|
# Batch based on time
|
||
|
earliest = models.QueuedMessage.earliest_for(
|
||
|
session, preference.user, preference.context)
|
||
|
now = datetime.datetime.utcnow()
|
||
|
delta = datetime.timedelta.total_seconds(now - earliest.created_on)
|
||
|
if preference.batch_delta is not None and preference.batch_delta <= delta:
|
||
|
_log.info("Sending digest for %r per time delta", preference.user.openid)
|
||
|
return True
|
||
|
except sqlalchemy.exc.SQLAlchemyError:
|
||
|
_log.exception('Failed to determine if the batch is ready for %s', preference.user)
|
||
|
session.rollback()
|
||
|
|
||
|
return False
|
||
|
|
||
|
|
||
|
@app.task(name='fmn.tasks.heat_fas_cache', ignore_results=True)
|
||
|
def heat_fas_cache(): # pragma: no cover
|
||
|
"""
|
||
|
Fetch all users from FAS and populate the local Redis cache.
|
||
|
|
||
|
This is helpful to do once on startup since we'll need everyone's email or
|
||
|
IRC nickname eventually.
|
||
|
"""
|
||
|
if config.app_conf['fasjson'].get('active'):
|
||
|
fmn_fasshim.make_fasjson_cache(**config.app_conf)
|
||
|
else:
|
||
|
fmn_fasshim.make_fas_cache(**config.app_conf)
|
||
|
|
||
|
|
||
|
@app.task(name='fmn.tasks.confirmations', ignore_results=True)
|
||
|
def confirmations():
|
||
|
"""
|
||
|
Load all pending confirmations, create formatted messages, and dispatch them to the
|
||
|
delivery service.
|
||
|
|
||
|
This is intended to be dispatched regularly via celery beat.
|
||
|
"""
|
||
|
session = models.Session()
|
||
|
try:
|
||
|
models.Confirmation.delete_expired(session)
|
||
|
pending = models.Confirmation.query.filter_by(status='pending').all()
|
||
|
broker_url = config.app_conf['celery']['broker']
|
||
|
with connections[Connection(broker_url)].acquire(block=True, timeout=60) as conn:
|
||
|
producer = conn.Producer()
|
||
|
for confirmation in pending:
|
||
|
message = None
|
||
|
if confirmation.context.name == 'email':
|
||
|
message = formatters.email_confirmation(confirmation)
|
||
|
else:
|
||
|
# The way the irc backend is currently written, it has to format the
|
||
|
# confirmation itself. For now, just send an empty message, but in the
|
||
|
# future it may be worth refactoring the irc backend to let us format here.
|
||
|
message = ''
|
||
|
recipient = {
|
||
|
confirmation.context.detail_name: confirmation.detail_value,
|
||
|
'user': confirmation.user.openid,
|
||
|
'triggered_by_links': False,
|
||
|
'confirmation': True,
|
||
|
}
|
||
|
backend_message = {
|
||
|
"context": confirmation.context.name,
|
||
|
"recipient": recipient,
|
||
|
"fedmsg": {},
|
||
|
"formatted_message": message,
|
||
|
}
|
||
|
_log.info('Dispatching confirmation message for %r', confirmation)
|
||
|
confirmation.set_status(session, 'valid')
|
||
|
routing_key = BACKEND_QUEUE_PREFIX + confirmation.context.name
|
||
|
producer.publish(backend_message, routing_key=routing_key,
|
||
|
declare=[Queue(routing_key, durable=True)])
|
||
|
session.commit()
|
||
|
except sqlalchemy.exc.SQLAlchemyError:
|
||
|
_log.exception('Unable to handle confirmations')
|
||
|
session.rollback()
|
||
|
finally:
|
||
|
models.Session.remove()
|
||
|
|
||
|
|
||
|
#: A Celery task that accepts a message as input and determines the recipients.
|
||
|
find_recipients = app.tasks[_FindRecipients.name]
|