Compare commits

...

3 Commits

Author SHA1 Message Date
Leo Hemsted
4d418b7f95 set delete(synchronize_session=False) on bulk deletes
When we're bulk updating, make sure we call `synchronize_session=False`,
and make sure that we then commit before we try and access any ORM
objects in the session that might be deleted.

Tutorial time:

when you delete, sqlalchemy needs to work out what to do with objects in
the session. "evaluate", "fetch", or False (do nothing). It wants to
remove items from the session if you're about to delete them, so that
you don't get confused about the state of objects

* evaluate compares the delete query to each item in the session in
  turn. this is the default. if you have lots in the session this could
  be super slow I guess but that's rarely the case for us. This can lead
  to errors if the column names are different to the model names, like
  on our notification and history models.
* fetch runs the delete query as if it's a select, and then checks the
  results of that vs the session. This could be slow.
* False doesn't do anything. This means the session will be stale and
  potentially will contain now-deleted items, until we call `commit` or
  `expire_all` on the session.

https://docs.sqlalchemy.org/en/13/orm/query.html?highlight=query.update#sqlalchemy.orm.query.Query.delete
2020-03-20 17:46:47 +00:00
David McDonald
33d85322c9 Change sql to chunk by hour to remove old email notifications
So since removing the subquery in the previous commit, we now are doing
all of the inserts using offsets and limits to group by 10,000 and deleting
all records in a single query (which could be up to as many as 10
million rows).

We want to avoid doing this, because both of these ways we think are
going to result in expensive queries. Therefore we have introduced our
own chunking of the notifications by hour periods meaning we do not need
to use offset and limits.

We estimate that GOV.UK email will at most send 600,000 notifications
per hour (175 per second * 60 * 60).
2020-03-20 16:52:09 +00:00
Rebecca Law
4dc1a48464 This option removes the subquery all together.
It has been recommended that subqueries are really inefficient especially on a delete statement.
2020-03-20 07:35:15 +00:00
4 changed files with 90 additions and 128 deletions

View File

@@ -102,7 +102,7 @@ def _delete_inbound_sms(datetime_to_delete_from, query_filter):
while number_deleted > 0:
_insert_inbound_sms_history(subquery, query_limit=query_limit)
number_deleted = InboundSms.query.filter(InboundSms.id.in_(subquery)).delete(synchronize_session='fetch')
number_deleted = InboundSms.query.filter(InboundSms.id.in_(subquery)).delete(synchronize_session=False)
deleted += number_deleted
return deleted

View File

@@ -307,19 +307,14 @@ def delete_notifications_older_than_retention_by_type(notification_type, qry_lim
).all()
deleted = 0
for f in flexible_data_retention:
days_of_retention = get_london_midnight_in_utc(
convert_utc_to_bst(datetime.utcnow()).date()) - timedelta(days=f.days_of_retention)
if notification_type == LETTER_TYPE:
_delete_letters_from_s3(
notification_type, f.service_id, days_of_retention, qry_limit
)
insert_update_notification_history(notification_type, days_of_retention, f.service_id)
current_app.logger.info(
"Deleting {} notifications for service id: {}".format(notification_type, f.service_id))
deleted += _delete_notifications(notification_type, days_of_retention, f.service_id, qry_limit)
day_to_delete_backwards_from = get_london_midnight_in_utc(
convert_utc_to_bst(datetime.utcnow()).date()) - timedelta(days=f.days_of_retention)
deleted += _move_notifications_to_notification_history(
notification_type, f.service_id, day_to_delete_backwards_from, qry_limit)
current_app.logger.info(
'Deleting {} notifications for services without flexible data retention'.format(notification_type))
@@ -329,59 +324,66 @@ def delete_notifications_older_than_retention_by_type(notification_type, qry_lim
service_ids_to_purge = db.session.query(Service.id).filter(Service.id.notin_(services_with_data_retention)).all()
for service_id in service_ids_to_purge:
if notification_type == LETTER_TYPE:
_delete_letters_from_s3(
notification_type, service_id, seven_days_ago, qry_limit
)
insert_update_notification_history(notification_type, seven_days_ago, service_id)
deleted += _delete_notifications(notification_type, seven_days_ago, service_id, qry_limit)
deleted += _move_notifications_to_notification_history(
notification_type, service_id, seven_days_ago, qry_limit)
current_app.logger.info('Finished deleting {} notifications'.format(notification_type))
return deleted
def _delete_notifications(notification_type, date_to_delete_from, service_id, query_limit):
subquery = db.session.query(
Notification.id
).join(NotificationHistory, NotificationHistory.id == Notification.id).filter(
Notification.notification_type == notification_type,
Notification.service_id == service_id,
Notification.created_at < date_to_delete_from,
).limit(query_limit).subquery()
def _move_notifications_to_notification_history(notification_type, service_id, day_to_delete_backwards_from, qry_limit):
deleted = 0
if notification_type == LETTER_TYPE:
_delete_letters_from_s3(
notification_type, service_id, day_to_delete_backwards_from, qry_limit
)
deleted = _delete_for_query(subquery)
stop = -1 # exclusive, we want to include 0
step = -1
for hour_delta in range(23, stop, step):
# We find the timestamp we want to delete all notifications backwards from
# We then start 23 hours ago, and do an insert notification history before deleting all notifications older
# We then look 22 hours ago, do an insert notifications history before deleting all notifications older
# We continue this until we reach the original timestamp we wanted to delete notifications backwardsfrom
# This enables us to break this into smaller database queries
timestamp_to_delete_backwards_from = day_to_delete_backwards_from - timedelta(hours=hour_delta)
subquery_for_test_keys = db.session.query(
Notification.id
).filter(
Notification.notification_type == notification_type,
Notification.service_id == service_id,
Notification.created_at < date_to_delete_from,
Notification.key_type == KEY_TYPE_TEST
).limit(query_limit).subquery()
if service_id == '539d63a1-701d-400d-ab11-f3ee2319d4d4':
current_app.logger.info(
"Beginning insert_update_notification_history for GOV.UK Email from {} backwards".format(
timestamp_to_delete_backwards_from
)
)
deleted += _delete_for_query(subquery_for_test_keys)
insert_update_notification_history(notification_type, timestamp_to_delete_backwards_from, service_id)
if service_id == '539d63a1-701d-400d-ab11-f3ee2319d4d4':
current_app.logger.info(
"Beginning _delete_notifications for GOV.UK Email {} backwards".format(
timestamp_to_delete_backwards_from
)
)
deleted += _delete_notifications(
notification_type, timestamp_to_delete_backwards_from, service_id
)
return deleted
def _delete_for_query(subquery):
number_deleted = db.session.query(Notification).filter(
Notification.id.in_(subquery)).delete(synchronize_session='fetch')
deleted = number_deleted
def _delete_notifications(notification_type, date_to_delete_from, service_id):
deleted = Notification.query.filter(
Notification.notification_type == notification_type,
Notification.service_id == service_id,
Notification.created_at < date_to_delete_from,
).delete(synchronize_session=False)
db.session.commit()
while number_deleted > 0:
number_deleted = db.session.query(Notification).filter(
Notification.id.in_(subquery)).delete(synchronize_session='fetch')
deleted += number_deleted
db.session.commit()
return deleted
def insert_update_notification_history(notification_type, date_to_delete_from, service_id, query_limit=10000):
offset = 0
def insert_update_notification_history(notification_type, date_to_delete_from, service_id):
notification_query = db.session.query(
*[x.name for x in NotificationHistory.__table__.c]
).filter(
@@ -390,29 +392,25 @@ def insert_update_notification_history(notification_type, date_to_delete_from, s
Notification.created_at < date_to_delete_from,
Notification.key_type != KEY_TYPE_TEST
)
notifications_count = notification_query.count()
while offset < notifications_count:
stmt = insert(NotificationHistory).from_select(
NotificationHistory.__table__.c,
notification_query.limit(query_limit).offset(offset)
)
stmt = insert(NotificationHistory).from_select(
NotificationHistory.__table__.c,
notification_query
)
stmt = stmt.on_conflict_do_update(
constraint="notification_history_pkey",
set_={
"notification_status": stmt.excluded.status,
"reference": stmt.excluded.reference,
"billable_units": stmt.excluded.billable_units,
"updated_at": stmt.excluded.updated_at,
"sent_at": stmt.excluded.sent_at,
"sent_by": stmt.excluded.sent_by
}
)
db.session.connection().execute(stmt)
db.session.commit()
offset += query_limit
stmt = stmt.on_conflict_do_update(
constraint="notification_history_pkey",
set_={
"notification_status": stmt.excluded.status,
"reference": stmt.excluded.reference,
"billable_units": stmt.excluded.billable_units,
"updated_at": stmt.excluded.updated_at,
"sent_at": stmt.excluded.sent_at,
"sent_by": stmt.excluded.sent_by
}
)
db.session.connection().execute(stmt)
db.session.commit()
def _delete_letters_from_s3(

View File

@@ -9,13 +9,13 @@ from freezegun import freeze_time
from app.dao.notifications_dao import (
delete_notifications_older_than_retention_by_type,
db,
insert_update_notification_history
)
from app.models import Notification, NotificationHistory
from tests.app.db import (
create_template,
create_notification,
create_notification_history,
create_service_data_retention,
create_service
)
@@ -75,7 +75,7 @@ def test_should_delete_notifications_by_type_after_seven_days(
):
mocker.patch("app.dao.notifications_dao.get_s3_bucket_objects")
email_template, letter_template, sms_template = _create_templates(sample_service)
# create one notification a day between 1st and 10th from 11:00 to 19:00 of each type
# create one notification a day between 1st and 10th from 01:00 to 11:00 of each type
for i in range(1, 11):
past_date = '2016-0{0}-{1:02d} {1:02d}:00:00.000000'.format(month, i)
with freeze_time(past_date):
@@ -157,17 +157,24 @@ def test_delete_notifications_inserts_notification_history(sample_service):
assert NotificationHistory.query.count() == 2
def test_delete_notifications_updates_notification_history(sample_email_template, mocker):
def test_delete_notifications_updates_notification_history(notify_db, sample_email_template, mocker):
mocker.patch("app.dao.notifications_dao.get_s3_bucket_objects")
notification = create_notification(template=sample_email_template, created_at=datetime.utcnow() - timedelta(days=8))
Notification.query.filter_by(id=notification.id).update(
{"status": "delivered",
"reference": "ses_reference",
"billable_units": 1, # I know we don't update this for emails but this is a unit test
"updated_at": datetime.utcnow(),
"sent_at": datetime.utcnow(),
"sent_by": "ses"
}
now = datetime.utcnow()
notification = create_notification(
template=sample_email_template,
created_at=datetime.utcnow() - timedelta(days=8),
reference="ses_reference",
billable_units=1,
updated_at=now,
sent_by="ses",
status='delivered'
)
create_notification_history(
id=notification.id,
template=sample_email_template,
created_at=datetime.utcnow() - timedelta(days=8),
status='sending',
)
delete_notifications_older_than_retention_by_type("email")
@@ -177,7 +184,7 @@ def test_delete_notifications_updates_notification_history(sample_email_template
assert history[0].status == 'delivered'
assert history[0].reference == 'ses_reference'
assert history[0].billable_units == 1
assert history[0].updated_at
assert history[0].updated_at == now
assert history[0].sent_by == 'ses'
@@ -227,21 +234,6 @@ def test_delete_notifications_does_try_to_delete_from_s3_when_letter_has_not_bee
mock_get_s3.assert_not_called()
@freeze_time("2016-01-10 12:00:00.000000")
def test_should_not_delete_notification_if_history_does_not_exist(sample_service, mocker):
mocker.patch("app.dao.notifications_dao.get_s3_bucket_objects")
mocker.patch("app.dao.notifications_dao.insert_update_notification_history")
with freeze_time('2016-01-01 12:00'):
email_template, letter_template, sms_template = _create_templates(sample_service)
create_notification(template=email_template, status='permanent-failure')
create_notification(template=sms_template, status='delivered')
create_notification(template=letter_template, status='temporary-failure')
assert Notification.query.count() == 3
delete_notifications_older_than_retention_by_type('sms')
assert Notification.query.count() == 3
assert NotificationHistory.query.count() == 0
def test_delete_notifications_calls_subquery_multiple_times(sample_template):
create_notification(template=sample_template, created_at=datetime.now() - timedelta(days=8))
create_notification(template=sample_template, created_at=datetime.now() - timedelta(days=8))
@@ -288,35 +280,6 @@ def test_insert_update_notification_history(sample_service):
assert notification_3.id in history_ids
def test_insert_update_notification_history_with_more_notifications_than_query_limit(mocker, sample_service):
template = create_template(sample_service, template_type='sms')
notification_1 = create_notification(template=template, created_at=datetime.utcnow() - timedelta(days=3))
notification_2 = create_notification(template=template, created_at=datetime.utcnow() - timedelta(days=8))
notification_3 = create_notification(template=template, created_at=datetime.utcnow() - timedelta(days=9))
other_types = ['email', 'letter']
for template_type in other_types:
t = create_template(service=sample_service, template_type=template_type)
create_notification(template=t, created_at=datetime.utcnow() - timedelta(days=3))
create_notification(template=t, created_at=datetime.utcnow() - timedelta(days=8))
db_connection_spy = mocker.spy(db.session, 'connection')
db_commit_spy = mocker.spy(db.session, 'commit')
insert_update_notification_history(
notification_type='sms', date_to_delete_from=datetime.utcnow() - timedelta(days=7),
service_id=sample_service.id, query_limit=1)
history = NotificationHistory.query.all()
assert len(history) == 2
history_ids = [x.id for x in history]
assert notification_1.id not in history_ids
assert notification_2.id in history_ids
assert notification_3.id in history_ids
assert db_connection_spy.call_count == 2
assert db_commit_spy.call_count == 2
def test_insert_update_notification_history_only_insert_update_given_service(sample_service):
other_service = create_service(service_name='another service')
other_template = create_template(service=other_service)

View File

@@ -324,7 +324,8 @@ def create_notification_history(
international=False,
phone_prefix=None,
created_by_id=None,
postage=None
postage=None,
id=None
):
assert job or template
if job:
@@ -341,7 +342,7 @@ def create_notification_history(
postage = 'second'
data = {
'id': uuid.uuid4(),
'id': id or uuid.uuid4(),
'job_id': job and job.id,
'job': job,
'service_id': template.service.id,