Skip to content
Snippets Groups Projects
Commit 7ab8787a authored by Tanner Prestegard's avatar Tanner Prestegard Committed by GraceDB
Browse files

alerts: fix race conditions in recipient determination

parent 7363e594
No related branches found
No related tags found
No related merge requests found
...@@ -74,7 +74,7 @@ More information about each field will be provided in the following section wher ...@@ -74,7 +74,7 @@ More information about each field will be provided in the following section wher
A few notes here: A few notes here:
- The label query field allows a complex filter on the requirement for which labels are/are not applied to an event or superevent. To construct a label query, combine label names with binary AND: ('&' or ',') or binary OR: '|'. They can also be negated with '~' or '-'. For N labels, there must be exactly N-1 binary operators. Parentheses are not allowed. It is suggested to specify at least one label that is not negated, or you may receive more notifications than you would like. - The label query field allows a complex filter on the requirement for which labels are/are not applied to an event or superevent. To construct a label query, combine label names with binary AND: ('&' or ',') or binary OR: '|'. They can also be negated with '~' or '-'. For N labels, there must be exactly N-1 binary operators. Parentheses are not allowed. It is suggested to specify at least one label that is not negated, or you may receive more notifications than you would like. Operator precedence is: NOT, AND, OR.
- You can specify either a set of labels or a label query, but not both. - You can specify either a set of labels or a label query, but not both.
- An event or superevent is considered a neutron star candidate if its source is thought to be a compact binary system where the secondary object has a mass less than 3 M\ :sub:`sun`\ . You can restrict your alerts to only events or superevents which are considered to be a neutron star candidate by checking the corresponding box in the form. - An event or superevent is considered a neutron star candidate if its source is thought to be a compact binary system where the secondary object has a mass less than 3 M\ :sub:`sun`\ . You can restrict your alerts to only events or superevents which are considered to be a neutron star candidate by checking the corresponding box in the form.
......
from pyparsing import oneOf, Literal
# List of characters which are equivalent to the corresponding
# logical operator
AND = ['&', ',']
OR = ['|']
NOT = ['~', '-']
# Pyparsing parsers
OPERATORS = {
'NOT': oneOf(NOT),
'AND': oneOf(AND),
'OR': Literal(OR),
}
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from events.models import Label
from events.shortcuts import is_event from events.shortcuts import is_event
from search.query.labels import filter_for_labels
from superevents.shortcuts import is_superevent from superevents.shortcuts import is_superevent
from .models import Contact, Notification from .models import Contact, Notification
from .utils import evaluate_label_queries
class CreationRecipientGetter(object): class CreationRecipientGetter(object):
queryset = Notification.objects.all() queryset = Notification.objects.all()
def __init__(self, event_or_superevent, **kwargs): def __init__(self, es, **kwargs):
self.es = event_or_superevent # NOTE: es = event_or_superevent
self.is_event_alert = is_event(event_or_superevent) self.is_event_alert = is_event(es)
self.event = event_or_superevent if self.is_event_alert \ self.event = es if self.is_event_alert else es.preferred_event
else event_or_superevent.preferred_event
self.process_kwargs(**kwargs) self.process_kwargs(**kwargs)
# event_or_superevent queryset - used for filtering by labels
self.es_qs = self.es._meta.model.objects.filter(pk=self.es.pk) # Explicitly get the values for a few things and store them on a
# class instance. This is because there is a possibility of race
# conditions if the event or superevent is updated while we are trying
# to figure out which notifications should trigger, which can take
# several seconds or more in production.
self.far = self.event.far
self.is_ns_candidate = self.event.is_ns_candidate()
# Force queryset evaluation with list()
self.label_names = list(es.labels.values_list('name', flat=True))
def process_kwargs(self, **kwargs): def process_kwargs(self, **kwargs):
pass pass
...@@ -29,12 +37,12 @@ class CreationRecipientGetter(object): ...@@ -29,12 +37,12 @@ class CreationRecipientGetter(object):
def get_far_filter(self): def get_far_filter(self):
query = Q(far_threshold__isnull=True) query = Q(far_threshold__isnull=True)
if self.event.far: if self.far:
query |= Q(far_threshold__gt=self.event.far) query |= Q(far_threshold__gt=self.far)
return query return query
def get_nscand_filter(self): def get_nscand_filter(self):
if self.event.is_ns_candidate(): if self.is_ns_candidate:
return Q() return Q()
return Q(ns_candidate=False) return Q(ns_candidate=False)
...@@ -64,16 +72,23 @@ class CreationRecipientGetter(object): ...@@ -64,16 +72,23 @@ class CreationRecipientGetter(object):
return Q() return Q()
def filter_for_labels(self, notifications): def filter_for_labels(self, notifications):
# Check notifications which do NOT have a label query. Check whether
# their labels are a subset of what is attached to the event or
# superevent. Notifications with no labels are automatically a
# subset.
# In this case, all the labels attached to the notification should
# be in the set attached to the event/superevent.
notification_pks = [] notification_pks = []
for n in notifications: label_set = set(self.label_names)
if n.labels.exists() and not n.label_query: for n in notifications.filter(label_query__isnull=True):
if not set(n.labels.all()).issubset(self.es.labels.all()): n_label_set = set(n.labels.values_list('name', flat=True))
continue if n_label_set.issubset(label_set):
elif n.label_query: notification_pks.append(n.pk)
qs_out = filter_for_labels(self.es_qs, n.label_query)
if not qs_out.exists(): # Check those with label queries
continue notification_qs = notifications.filter(label_query__isnull=False)
notification_pks.append(n.pk) pks = evaluate_label_queries(self.label_names, notification_qs)
notification_pks.extend(pks)
return Notification.objects.filter(pk__in=notification_pks) return Notification.objects.filter(pk__in=notification_pks)
def get_contacts_for_notifications(self, notifications): def get_contacts_for_notifications(self, notifications):
...@@ -139,13 +154,13 @@ class UpdateRecipientGetter(CreationRecipientGetter): ...@@ -139,13 +154,13 @@ class UpdateRecipientGetter(CreationRecipientGetter):
query = Q(pk__in=[]) query = Q(pk__in=[])
# Then we add other options that could possibly match # Then we add other options that could possibly match
if self.event.far is not None: if self.far is not None:
if self.old_far is None: if self.old_far is None:
query |= Q(far_threshold__gt=self.event.far) query |= Q(far_threshold__gt=self.far)
else: else:
query |= (Q(far_threshold__lte=self.old_far) & query |= (Q(far_threshold__lte=self.old_far) &
Q(far_threshold__gt=self.event.far)) Q(far_threshold__gt=self.far))
if self.old_nscand is False and self.event.is_ns_candidate(): if self.old_nscand is False and self.is_ns_candidate:
query |= Q(ns_candidate=True) query |= Q(ns_candidate=True)
return query return query
...@@ -183,14 +198,9 @@ class LabelRemovedRecipientGetter(LabelAddedRecipientGetter): ...@@ -183,14 +198,9 @@ class LabelRemovedRecipientGetter(LabelAddedRecipientGetter):
# Only notifications with a label query should be triggered # Only notifications with a label query should be triggered
# by a label_removed alert, since notifications with a # by a label_removed alert, since notifications with a
# label set can only have non-negated labels. # label set can only have non-negated labels.
notification_pks = [] notification_qs = notifications.filter(label_query__isnull=False)
for n in notifications: pks = evaluate_label_queries(self.label_names, notification_qs)
if n.label_query: return Notification.objects.filter(pk__in=pks)
qs_out = filter_for_labels(self.es_qs, n.label_query)
if not qs_out.exists():
continue
notification_pks.append(n.pk)
return Notification.objects.filter(pk__in=notification_pks)
# Dict which maps alert types to recipient getter classes # Dict which maps alert types to recipient getter classes
......
...@@ -1699,7 +1699,7 @@ def test_event_label_removed_alerts( ...@@ -1699,7 +1699,7 @@ def test_event_label_removed_alerts(
# Other tests ----------------------------------------------------------------- # Other tests -----------------------------------------------------------------
@pytest.mark.django_db @pytest.mark.django_db
def test_complex_label_query(superevent): def test_complex_label_query(superevent):
# NOTE: L1 & ~L2 | L3 == L1 & (~L2 | L3) # NOTE: L1 & ~L2 | L3 == (L1 & ~L2) | L3
n = Notification.objects.create( n = Notification.objects.create(
label_query='L1 & ~L2 | L3', label_query='L1 & ~L2 | L3',
user=superevent.submitter, user=superevent.submitter,
...@@ -1733,7 +1733,8 @@ def test_complex_label_query(superevent): ...@@ -1733,7 +1733,8 @@ def test_complex_label_query(superevent):
superevent.labelling_set.create(creator=superevent.submitter, label=l3) superevent.labelling_set.create(creator=superevent.submitter, label=l3)
recipient_getter = LabelAddedRecipientGetter(superevent, label=l3) recipient_getter = LabelAddedRecipientGetter(superevent, label=l3)
matched_notifications = recipient_getter.get_notifications() matched_notifications = recipient_getter.get_notifications()
assert matched_notifications.count() == 0 assert matched_notifications.count() == 1
assert matched_notifications.first().description == n.description
# Test label added recipients for L1 being added (with L2) # Test label added recipients for L1 being added (with L2)
l1 = Label.objects.get(name='L1') l1 = Label.objects.get(name='L1')
......
from __future__ import absolute_import from __future__ import absolute_import
from pyparsing import oneOf, Literal, Optional, ZeroOrMore, StringEnd, Suppress from pyparsing import (
oneOf, Optional, ZeroOrMore, StringEnd, Suppress, infixNotation, opAssoc,
)
import re import re
from django.db.models import Q
from events.models import Label from events.models import Label
from .constants import OPERATORS
def get_label_parser():
return oneOf(list(Label.objects.values_list('name', flat=True)))
OPERATORS = {
'AND': oneOf(", &"),
'OR': Literal("|"),
'NOT': oneOf("- ~"),
}
def parse_label_query(s, keep_binary_ops=False):
"""
Parses a label query into a list. The output is a list whose elements
depend on the options:
def parse_label_query(s): >>> parse_label_query('A & B', keep_binary_ops=False)
"""Parses a label query into a list of label names""" ['A', 'B']
>>> parse_label_query('A & B', keep_binary_ops=True)
['A', '&', 'B']
"""
# Parser for one label name # Parser for one label name
label = oneOf(list(Label.objects.all().values_list('name', flat=True))) label = get_label_parser()
# "intermediate" parser - between labels should be AND or OR and then # "intermediate" parser - between labels should be AND or OR and then
# an optional NOT # an optional NOT
im = Suppress((OPERATORS['AND'] ^ OPERATORS['OR']) + im = (OPERATORS['AND'] ^ OPERATORS['OR']) + Optional(OPERATORS['NOT'])
Optional(OPERATORS['NOT'])) if not keep_binary_ops:
im = Suppress(im)
# Full parser: optional NOT and a label, then zero or more # Full parser: optional NOT and a label, then zero or more
# "intermediate" + label combos, then string end # "intermediate" + label combos, then string end
labelQ = Suppress(Optional(OPERATORS['NOT'])) + label + \ optional_initial_not = Optional(OPERATORS['NOT'])
if not keep_binary_ops:
optional_initial_not = Suppress(optional_initial_not)
label_expr = optional_initial_not + label + \
ZeroOrMore(im + label) + StringEnd() ZeroOrMore(im + label) + StringEnd()
return labelQ.parseString(s).asList() return label_expr.parseString(s).asList()
def convert_superevent_id_to_speech(sid): def convert_superevent_id_to_speech(sid):
...@@ -44,3 +54,33 @@ def convert_superevent_id_to_speech(sid): ...@@ -44,3 +54,33 @@ def convert_superevent_id_to_speech(sid):
# and make uppercase # and make uppercase
twilio_str = " ".join(grps).replace(' 0', ' O').upper() twilio_str = " ".join(grps).replace(' 0', ' O').upper()
return twilio_str return twilio_str
def get_label_query_parser(label_name_list):
label_parser = get_label_parser()
label_parser.setParseAction(lambda toks: toks[0] in label_name_list)
label_query_parser = infixNotation(label_parser,
[
(OPERATORS['NOT'], 1, opAssoc.RIGHT, lambda toks: not toks[0][1]),
(OPERATORS['AND'], 2, opAssoc.LEFT, lambda toks: all(toks[0][0::2])),
(OPERATORS['OR'], 2, opAssoc.LEFT, lambda toks: any(toks[0][0::2])),
]
)
return label_query_parser
def evaluate_label_queries(label_name_list, notifications):
"""
Takes in a list of label names and a queryset of Notifications,
returns pks of Notifications which have a label_query and that
label_query is evaluates to True
"""
label_query_parser = get_label_query_parser(label_name_list)
notification_pks = []
for n in notifications.filter(label_query__isnull=False):
valid = label_query_parser.parseString(n.label_query)[0]
if valid:
notification_pks.append(n.pk)
return notification_pks
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment