Merge branch 'main' of https://github.com/GSA/notifications-api into invite-expiration-fix

This commit is contained in:
Andrew Shumway
2025-01-16 08:59:38 -07:00
17 changed files with 278 additions and 46 deletions

View File

@@ -1,10 +1,11 @@
from datetime import timedelta
import json
from datetime import datetime, timedelta
from flask import current_app
from sqlalchemy import between
from sqlalchemy.exc import SQLAlchemyError
from app import notify_celery, zendesk_client
from app import notify_celery, redis_store, zendesk_client
from app.celery.tasks import (
get_recipient_csv_and_template_and_sender_id,
process_incomplete_jobs,
@@ -24,6 +25,7 @@ from app.dao.jobs_dao import (
find_missing_row_for_job,
)
from app.dao.notifications_dao import (
dao_batch_insert_notifications,
dao_close_out_delivery_receipts,
dao_update_delivery_receipts,
notifications_not_yet_sent,
@@ -34,7 +36,7 @@ from app.dao.services_dao import (
)
from app.dao.users_dao import delete_codes_older_created_more_than_a_day_ago
from app.enums import JobStatus, NotificationType
from app.models import Job
from app.models import Job, Notification
from app.notifications.process_notifications import send_notification_to_queue
from app.utils import utc_now
from notifications_utils import aware_utcnow
@@ -288,3 +290,51 @@ def process_delivery_receipts(self):
)
def cleanup_delivery_receipts(self):
dao_close_out_delivery_receipts()
@notify_celery.task(bind=True, name="batch-insert-notifications")
def batch_insert_notifications(self):
batch = []
# TODO We probably need some way to clear the list if
# things go haywire. A command?
# with redis_store.pipeline():
# while redis_store.llen("message_queue") > 0:
# redis_store.lpop("message_queue")
# current_app.logger.info("EMPTY!")
# return
current_len = redis_store.llen("message_queue")
with redis_store.pipeline():
# since this list is being fed by other processes, just grab what is available when
# this call is made and process that.
count = 0
while count < current_len:
count = count + 1
notification_bytes = redis_store.lpop("message_queue")
notification_dict = json.loads(notification_bytes.decode("utf-8"))
notification_dict["status"] = notification_dict.pop("notification_status")
if not notification_dict.get("created_at"):
notification_dict["created_at"] = utc_now()
elif isinstance(notification_dict["created_at"], list):
notification_dict["created_at"] = notification_dict["created_at"][0]
notification = Notification(**notification_dict)
if notification is not None:
batch.append(notification)
try:
dao_batch_insert_notifications(batch)
except Exception:
current_app.logger.exception("Notification batch insert failed")
for n in batch:
# Use 'created_at' as a TTL so we don't retry infinitely
notification_time = n.created_at
if isinstance(notification_time, str):
notification_time = datetime.fromisoformat(n.created_at)
if notification_time < utc_now() - timedelta(seconds=50):
current_app.logger.warning(
f"Abandoning stale data, could not write to db: {n.serialize_for_redis(n)}"
)
continue
else:
redis_store.rpush("message_queue", json.dumps(n.serialize_for_redis(n)))

View File

@@ -256,7 +256,7 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i
)
)
provider_tasks.deliver_sms.apply_async(
[str(saved_notification.id)], queue=QueueNames.SEND_SMS
[str(saved_notification.id)], queue=QueueNames.SEND_SMS, countdown=60
)
current_app.logger.debug(

View File

@@ -789,6 +789,17 @@ def _update_template(id, name, template_type, content, subject):
db.session.commit()
@notify_command(name="clear-redis-list")
@click.option("-n", "--name_of_list", required=True)
def clear_redis_list(name_of_list):
my_len_before = redis_store.llen(name_of_list)
redis_store.ltrim(name_of_list, 1, 0)
my_len_after = redis_store.llen(name_of_list)
current_app.logger.info(
f"Cleared redis list {name_of_list}. Before: {my_len_before} after {my_len_after}"
)
@notify_command(name="update-templates")
def update_templates():
with open(current_app.config["CONFIG_FILES"] + "/templates.json") as f:

View File

@@ -208,6 +208,11 @@ class Config(object):
"schedule": timedelta(minutes=82),
"options": {"queue": QueueNames.PERIODIC},
},
"batch-insert-notifications": {
"task": "batch-insert-notifications",
"schedule": 10.0,
"options": {"queue": QueueNames.PERIODIC},
},
"expire-or-delete-invitations": {
"task": "expire-or-delete-invitations",
"schedule": timedelta(minutes=66),

View File

@@ -827,3 +827,11 @@ def dao_close_out_delivery_receipts():
current_app.logger.info(
f"Marked {result.rowcount} notifications as technical failures"
)
def dao_batch_insert_notifications(batch):
db.session.bulk_save_objects(batch)
db.session.commit()
current_app.logger.info(f"Batch inserted notifications: {len(batch)}")
return len(batch)

View File

@@ -5,7 +5,7 @@ from flask import current_app, url_for
from sqlalchemy import CheckConstraint, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.declarative import DeclarativeMeta, declared_attr
from sqlalchemy.orm import validates
from sqlalchemy.orm.collections import attribute_mapped_collection
@@ -1694,6 +1694,33 @@ class Notification(db.Model):
else:
return None
def serialize_for_redis(self, obj):
if isinstance(obj.__class__, DeclarativeMeta):
fields = {}
for column in obj.__table__.columns:
if column.name == "notification_status":
new_name = "status"
value = getattr(obj, new_name)
elif column.name == "created_at":
if isinstance(obj.created_at, str):
value = obj.created_at
else:
value = (obj.created_at.strftime("%Y-%m-%d %H:%M:%S"),)
elif column.name in ["sent_at", "completed_at"]:
value = None
elif column.name.endswith("_id"):
value = getattr(obj, column.name)
value = str(value)
else:
value = getattr(obj, column.name)
if column.name in ["message_id", "api_key_id"]:
pass # do nothing because we don't have the message id yet
else:
fields[column.name] = value
return fields
raise ValueError("Provided object is not a SQLAlchemy instance")
def serialize_for_csv(self):
serialized = {
"row_number": (

View File

@@ -1,3 +1,5 @@
import json
import os
import uuid
from flask import current_app
@@ -11,7 +13,7 @@ from app.dao.notifications_dao import (
dao_notification_exists,
get_notification_by_id,
)
from app.enums import KeyType, NotificationStatus, NotificationType
from app.enums import NotificationStatus, NotificationType
from app.errors import BadRequestError
from app.models import Notification
from app.utils import hilite, utc_now
@@ -139,18 +141,18 @@ def persist_notification(
# if simulated create a Notification model to return but do not persist the Notification to the dB
if not simulated:
current_app.logger.info("Firing dao_create_notification")
dao_create_notification(notification)
if key_type != KeyType.TEST and current_app.config["REDIS_ENABLED"]:
current_app.logger.info(
"Redis enabled, querying cache key for service id: {}".format(
service.id
if notification.notification_type == NotificationType.SMS:
# it's just too hard with redis and timing to test this here
if os.getenv("NOTIFY_ENVIRONMENT") == "test":
dao_create_notification(notification)
else:
redis_store.rpush(
"message_queue",
json.dumps(notification.serialize_for_redis(notification)),
)
)
else:
dao_create_notification(notification)
current_app.logger.info(
f"{notification_type} {notification_id} created at {notification_created_at}"
)
return notification
@@ -172,7 +174,7 @@ def send_notification_to_queue_detached(
deliver_task = provider_tasks.deliver_email
try:
deliver_task.apply_async([str(notification_id)], queue=queue)
deliver_task.apply_async([str(notification_id)], queue=queue, countdown=60)
except Exception:
dao_delete_notifications_by_id(notification_id)
raise