squash.booking.lambdas.core.RuleManager.java Source code

Java tutorial

Introduction

Here is the source code for squash.booking.lambdas.core.RuleManager.java

Source

/**
 * Copyright 2016-2017 Robin Steel
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package squash.booking.lambdas.core;

import squash.deployment.lambdas.utils.RetryHelper;
import squash.deployment.lambdas.utils.RetryHelper.ThrowingSupplier;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.regions.Region;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.amazonaws.services.simpledb.model.Attribute;
import com.amazonaws.services.simpledb.model.ReplaceableAttribute;
import com.amazonaws.services.sns.AmazonSNS;
import com.amazonaws.services.sns.AmazonSNSClientBuilder;
import com.google.common.collect.Sets;

import java.io.IOException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;

/**
 * Manages all booking rules and their exclusions.
 *
 * <p>This manages all booking rules and exclusions and their persistence in the
 * database - which is currently SimpleDB. The database interactions are handled
 * using an {@link IOptimisticPersister IOptimisticPersister}.
 * 
 * @author robinsteel19@outlook.com (Robin Steel)
 */
public class RuleManager implements IRuleManager {
    private String ruleItemName;
    private Integer maxNumberOfRules = 100;
    protected Integer maxNumberOfDatesToExclude = 30;
    private Region region;
    private String adminSnsTopicArn;
    protected IOptimisticPersister optimisticPersister;
    private IBookingManager bookingManager;
    ILifecycleManager lifecycleManager;
    private LambdaLogger logger;
    private Boolean initialised = false;

    @Override
    public final void initialise(IBookingManager bookingManager, ILifecycleManager lifecycleManager,
            LambdaLogger logger) throws Exception {

        if (initialised) {
            throw new IllegalStateException("The rule manager has already been initialised");
        }

        this.bookingManager = bookingManager;
        this.lifecycleManager = lifecycleManager;
        this.logger = logger;
        ruleItemName = "BookingRulesAndExclusions";
        this.optimisticPersister = getOptimisticPersister();
        optimisticPersister.initialise(maxNumberOfRules, logger);

        adminSnsTopicArn = getEnvironmentVariable("AdminSNSTopicArn");
        region = Region.getRegion(Regions.fromName(getEnvironmentVariable("AWS_REGION")));
        initialised = true;
    }

    @Override
    public Set<BookingRule> createRule(BookingRule bookingRuleToCreate, boolean isSquashServiceUserCall)
            throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        // We retry the put of the rule if necessary if we get a
        // ConditionalCheckFailed exception, i.e. if someone else modifies the
        // database between us reading and writing it.
        return RetryHelper.DoWithRetries(() -> {
            Set<BookingRule> bookingRules = null;

            // Check that non-recurring rule is not for a date in the past.
            if (!bookingRuleToCreate.getIsRecurring()) {
                if ((new SimpleDateFormat("yyyy-MM-dd")).parse(bookingRuleToCreate.getBooking().getDate())
                        .before(new SimpleDateFormat("yyyy-MM-dd")
                                .parse(getCurrentLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))))) {
                    logger.log(
                            "Cannot add non-recurring booking rule for a date in the past, so throwing a 'Booking rule creation failed' exception");
                    throw new Exception("Booking rule creation failed");
                }
            }

            // We should POST or DELETE to the BookingRuleExclusion resource,
            // with a BookingRule, and an exclusion date. This will call through
            // to the addBookingRuleExclusion or deleteBookingRuleExclusion
            // methods on this manager.
            logger.log("About to create booking rule in simpledb: " + bookingRuleToCreate);
            ImmutablePair<Optional<Integer>, Set<BookingRule>> versionedBookingRules = getVersionedBookingRules();
            bookingRules = versionedBookingRules.right;

            // Check that the rule we're creating does not clash with an
            // existing rule.
            if (doesRuleClash(bookingRuleToCreate, bookingRules)) {
                logger.log(
                        "Cannot create rule as it clashes with existing rule, so throwing a 'Booking rule creation failed - rule would clash' exception");
                throw new Exception("Booking rule creation failed - rule would clash");
            }

            logger.log("The new rule does not clash with existing rules - so proceeding to create rule");

            String attributeName = getAttributeNameFromBookingRule(bookingRuleToCreate);
            String attributeValue = "";
            if (bookingRuleToCreate.getDatesToExclude().length > 0) {
                attributeValue = StringUtils.join(bookingRuleToCreate.getDatesToExclude(), ",");
            }
            logger.log("ItemName: " + ruleItemName);
            logger.log("AttributeName: " + attributeName);
            logger.log("AttributeValue: " + attributeValue);
            ReplaceableAttribute bookingRuleAttribute = new ReplaceableAttribute();
            bookingRuleAttribute.setName(attributeName);
            bookingRuleAttribute.setValue(attributeValue);

            optimisticPersister.put(ruleItemName, versionedBookingRules.left, bookingRuleAttribute);
            bookingRules.add(bookingRuleToCreate);
            return bookingRules;
        }, Exception.class, Optional.of("Database put failed - conditional check failed"), logger);
    }

    @Override
    public List<BookingRule> getRules(boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(true, isSquashServiceUserCall);

        logger.log("About to get all booking rules from simpledb");

        List<BookingRule> bookingRules = new ArrayList<>();
        bookingRules.addAll(getVersionedBookingRules().right);
        return bookingRules;
    }

    @Override
    public void deleteRule(BookingRule bookingRuleToDelete, boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        logger.log("About to delete booking rule from simpledb: " + bookingRuleToDelete.toString());

        String attributeName = getAttributeNameFromBookingRule(bookingRuleToDelete);
        String attributeValue = StringUtils.join(bookingRuleToDelete.getDatesToExclude(), ",");
        logger.log("Booking rule attribute name is: " + attributeName);
        logger.log("Booking rule attribute value is: " + attributeValue);
        Attribute attribute = new Attribute();
        attribute.setName(attributeName);
        attribute.setValue(attributeValue);
        optimisticPersister.delete(ruleItemName, attribute);

        logger.log("Deleted booking rule.");
        return;
    }

    @Override
    public void deleteAllBookingRules(boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        logger.log("Getting all booking rules to delete");
        List<BookingRule> bookingRules = getRules(isSquashServiceUserCall);
        logger.log("Found " + bookingRules.size() + " booking rules to delete");
        logger.log("About to delete all booking rules");
        for (BookingRule bookingRule : bookingRules) {
            RetryHelper.DoWithRetries(() -> {
                deleteRule(bookingRule, isSquashServiceUserCall);
                return null;
            }, AmazonServiceException.class, Optional.of("429"), logger);
        }
        logger.log("Deleted all booking rules");
    }

    @Override
    public Optional<BookingRule> addRuleExclusion(String dateToExclude, BookingRule bookingRuleToAddExclusionTo,
            boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        logger.log("About to add exclusion for " + dateToExclude + " to booking rule: "
                + bookingRuleToAddExclusionTo.toString());

        // We retry the addition of the exclusion if necessary if we get a
        // ConditionalCheckFailed exception, i.e. if someone else modifies
        // the database between us reading and writing it.
        return RetryHelper.DoWithRetries((ThrowingSupplier<Optional<BookingRule>>) (() -> {
            ImmutablePair<Optional<Integer>, Set<BookingRule>> versionedBookingRules = getVersionedBookingRules();
            Set<BookingRule> existingBookingRules = versionedBookingRules.right;

            // Check the BookingRule we're adding the exclusion to still
            // exists
            Optional<BookingRule> existingRule = existingBookingRules.stream()
                    .filter(rule -> rule.equals(bookingRuleToAddExclusionTo)).findFirst();
            if (!existingRule.isPresent()) {
                logger.log("Trying to add an exclusion to a booking rule that does not exist.");
                throw new Exception("Booking rule exclusion addition failed");
            }

            // Check rule is recurring - we cannot add exclusions to
            // non-recurring rules
            if (!existingRule.get().getIsRecurring()) {
                logger.log("Trying to add an exclusion to a non-recurring booking rule.");
                throw new Exception("Booking rule exclusion addition failed");
            }

            // Check that the rule exclusion we're creating does not exist
            // already.
            if (ArrayUtils.contains(existingRule.get().getDatesToExclude(), dateToExclude)) {
                logger.log("An identical booking rule exclusion exists already - so quitting early");
                return Optional.empty();
            }

            // Check the exclusion is for the right day of the week.
            DayOfWeek dayToExclude = dayOfWeekFromDate(dateToExclude);
            DayOfWeek dayOfBookingRule = dayOfWeekFromDate(existingRule.get().getBooking().getDate());
            if (!dayToExclude.equals(dayOfBookingRule)) {
                logger.log("Exclusion being added and target booking rule are for different days of the week.");
                throw new Exception("Booking rule exclusion addition failed");
            }

            // Check it is not in the past, relative to now, or to the
            // Booking rule start date.
            Date bookingRuleStartDate = new SimpleDateFormat("yyyy-MM-dd")
                    .parse(existingRule.get().getBooking().getDate());
            Date excludedDate = new SimpleDateFormat("yyyy-MM-dd").parse(dateToExclude);
            if (excludedDate.before(bookingRuleStartDate)) {
                logger.log("Exclusion being added is before target booking rule start date.");
                throw new Exception("Booking rule exclusion addition failed");
            }
            if (excludedDate.before(new SimpleDateFormat("yyyy-MM-dd")
                    .parse(getCurrentLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))))) {
                logger.log("Exclusion being added is in the past.");
                throw new Exception("Booking rule exclusion addition failed");
            }

            // Check we'll not exceed the maximum number of dates to exclude
            // (this limit is here as SimpleDB has a 1024-byte limit for
            // attribute
            // values).
            Set<String> datesToExclude = Sets.newHashSet(bookingRuleToAddExclusionTo.getDatesToExclude());
            if (datesToExclude.size() >= maxNumberOfDatesToExclude) {
                logger.log("The maximum number of booking rule exclusions(" + maxNumberOfDatesToExclude
                        + ") exists already.");
                throw new Exception("Booking rule exclusion addition failed - too many exclusions");
            }

            logger.log("Proceeding to add the new rule exclusion");
            datesToExclude.add(dateToExclude);
            String attributeName = getAttributeNameFromBookingRule(bookingRuleToAddExclusionTo);
            String attributeValue = StringUtils.join(datesToExclude, ",");
            logger.log("ItemName: " + ruleItemName);
            logger.log("AttributeName: " + attributeName);
            logger.log("AttributeValue: " + attributeValue);
            ReplaceableAttribute bookingRuleAttribute = new ReplaceableAttribute();
            bookingRuleAttribute.setName(attributeName);
            bookingRuleAttribute.setValue(attributeValue);
            bookingRuleAttribute.setReplace(true);

            optimisticPersister.put(ruleItemName, versionedBookingRules.left, bookingRuleAttribute);
            BookingRule updatedBookingRule = new BookingRule(bookingRuleToAddExclusionTo);
            updatedBookingRule.setDatesToExclude(datesToExclude.toArray(new String[datesToExclude.size()]));
            logger.log("Added new rule exclusion");
            return Optional.of(updatedBookingRule);
        }), Exception.class, Optional.of("Database put failed - conditional check failed"), logger);
    }

    @Override
    public Optional<BookingRule> deleteRuleExclusion(String dateNotToExclude,
            BookingRule bookingRuleToDeleteExclusionFrom, boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        logger.log("About to delete exclusion for " + dateNotToExclude + " from booking rule: "
                + bookingRuleToDeleteExclusionFrom.toString());

        // We retry the deletion of the exclusion if necessary if we get a
        // ConditionalCheckFailed exception, i.e. if someone else modifies
        // the database between us reading and writing it.
        return RetryHelper.DoWithRetries((ThrowingSupplier<Optional<BookingRule>>) (() -> {
            ImmutablePair<Optional<Integer>, Set<BookingRule>> versionedBookingRules = getVersionedBookingRules();
            Set<BookingRule> existingBookingRules = versionedBookingRules.right;

            // Check the BookingRule we're deleting the exclusion from still
            // exists
            Optional<BookingRule> existingRule = existingBookingRules.stream()
                    .filter(rule -> rule.equals(bookingRuleToDeleteExclusionFrom)).findFirst();
            if (!existingRule.isPresent()) {
                logger.log(
                        "Trying to delete an exclusion from a booking rule that does not exist, so swallowing and continuing");
                return Optional.empty();
            }

            // Check that the rule exclusion we're deleting still exists.
            if (!Arrays.asList(existingRule.get().getDatesToExclude()).contains(dateNotToExclude)) {
                logger.log("The booking rule exclusion being deleted no longer exists - so quitting early");
                return Optional.empty();
            }

            // Check deleting this exclusion does not cause a latent rule
            // clash to manifest. Do by pretending to add this rule again.
            existingBookingRules.remove(existingRule.get());
            Set<String> datesToExclude = Sets.newHashSet(bookingRuleToDeleteExclusionFrom.getDatesToExclude());
            datesToExclude.remove(dateNotToExclude);
            existingRule.get().setDatesToExclude(datesToExclude.toArray(new String[datesToExclude.size()]));
            if (doesRuleClash(existingRule.get(), existingBookingRules)) {
                logger.log("Cannot delete booking rule exclusion as remaining rules would then clash");
                throw new Exception("Booking rule exclusion deletion failed - latent clash exists");
            }

            logger.log("Proceeding to delete the rule exclusion");
            String attributeName = getAttributeNameFromBookingRule(bookingRuleToDeleteExclusionFrom);
            String attributeValue = StringUtils.join(datesToExclude, ",");
            logger.log("ItemName: " + ruleItemName);
            logger.log("AttributeName: " + attributeName);
            logger.log("AttributeValue: " + attributeValue);
            ReplaceableAttribute bookingRuleAttribute = new ReplaceableAttribute();
            bookingRuleAttribute.setName(attributeName);
            bookingRuleAttribute.setValue(attributeValue);
            bookingRuleAttribute.setReplace(true);

            optimisticPersister.put(ruleItemName, versionedBookingRules.left, bookingRuleAttribute);
            BookingRule updatedBookingRule = new BookingRule(bookingRuleToDeleteExclusionFrom);
            updatedBookingRule.setDatesToExclude(datesToExclude.toArray(new String[datesToExclude.size()]));
            logger.log("Deleted rule exclusion");
            return Optional.of(updatedBookingRule);
        }), Exception.class, Optional.of("Database put failed - conditional check failed"), logger);
    }

    @Override
    public List<Booking> applyRules(String date, boolean isSquashServiceUserCall) throws Exception {

        if (!initialised) {
            throw new IllegalStateException("The rule manager has not been initialised");
        }

        lifecycleManager.throwIfOperationInvalidForCurrentLifecycleState(false, isSquashServiceUserCall);

        List<Booking> ruleBookings = new ArrayList<>();
        try {
            // Apply rules only if date is not in the past.
            Boolean applyDateIsInPast = (new SimpleDateFormat("yyyy-MM-dd").parse(date))
                    .before((new SimpleDateFormat("yyyy-MM-dd")
                            .parse(getCurrentLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))));
            if (!applyDateIsInPast) {
                logger.log("About to apply booking rules for date: " + date);
                for (BookingRule rule : getRules(false)) {
                    logger.log("Considering booking rule: " + rule);
                    Boolean ruleIsNonRecurringAndForApplyDate = !rule.getIsRecurring()
                            && rule.getBooking().getDate().equals(date);
                    Boolean ruleIsRecurringAndStartsOnOrBeforeApplyDate = rule.getIsRecurring()
                            && dayOfWeekFromDate(rule.getBooking().getDate()).equals(dayOfWeekFromDate(date))
                            && !((new SimpleDateFormat("yyyy-MM-dd")).parse(date).before(
                                    (new SimpleDateFormat("yyyy-MM-dd")).parse(rule.getBooking().getDate())));
                    if (ruleIsNonRecurringAndForApplyDate || ruleIsRecurringAndStartsOnOrBeforeApplyDate) {
                        if (ruleIsRecurringAndStartsOnOrBeforeApplyDate) {
                            logger.log(
                                    "Booking rule recurring and starts before date that rules are being applied to: "
                                            + rule.toString());
                            // Does this rule have a relevant exclusion?
                            if (ArrayUtils.contains(rule.getDatesToExclude(), date)) {
                                logger.log("Recurring rule does not apply as it has a matching rule exclusion");
                                continue;
                            }
                        } else {
                            logger.log(
                                    "Booking rule non-recurring but applies to date that rules are being applied to.");
                        }
                        logger.log("Applying booking rule to create booking: " + rule.toString());
                        Booking booking = rule.getBooking();
                        booking.setDate(date);
                        bookingManager.createBooking(booking, false);
                        ruleBookings.add(booking);
                        // Short sleep to minimise chance of getting TooManyRequests error
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException interruptedException) {
                            logger.log("Sleep before applying next rule has been interrupted.");
                        }
                        logger.log("Rule-based booking created.");
                    } else {
                        logger.log("Rule does not apply to date that rules are being applied to.");
                    }
                }
            }
        } catch (Exception exception) {
            logger.log("Exception caught while applying booking rules - so notifying sns topic");
            getSNSClient().publish(adminSnsTopicArn,
                    "Apologies - but there was an error applying the booking rules for " + date
                            + ". Please make the rule bookings for this date manually instead. The error message was: "
                            + exception.getMessage(),
                    "Sqawsh booking rules failed to apply");
            // Rethrow
            throw exception;
        }

        logger.log("About to purge expired rules and exclusions.");
        purgeExpiredRulesAndRuleExclusions();
        logger.log("Purged expired rules and exclusions.");

        return ruleBookings;
    }

    private DayOfWeek dayOfWeekFromDate(String date) throws ParseException {
        DateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
        formatter.setTimeZone(TimeZone.getTimeZone("Europe/London"));
        return formatter.parse(date).toInstant().atZone(TimeZone.getTimeZone("Europe/London").toZoneId())
                .toLocalDate().getDayOfWeek();
    }

    private Boolean doesRuleClash(BookingRule newBookingRule, Set<BookingRule> existingBookingRules)
            throws ParseException {

        logger.log("Determining if new rule clashes with existing rule.");

        // Get all existing rules that apply to the same day of the week...
        DayOfWeek newDay = dayOfWeekFromDate(newBookingRule.getBooking().getDate());
        logger.log("New rule applies to day of the week: " + newDay);
        Set<BookingRule> sameDayRules = new HashSet<>();
        for (BookingRule bookingRule : existingBookingRules) {
            if (dayOfWeekFromDate(bookingRule.getBooking().getDate()).equals(newDay)) {
                sameDayRules.add(bookingRule);
            }
        }
        logger.log(sameDayRules.size() + " existing rules also apply to this day of the week");

        // ..and all of those that overlap the same courttimeblock
        Set<BookingRule> sameDayOverlappingRules = new HashSet<>();
        // Get court/time pairs for the new rule
        Set<ImmutablePair<Integer, Integer>> newBookedCourts = new HashSet<>();
        addBookingRuleToSet(newBookingRule, newBookedCourts);

        for (BookingRule bookingRule : sameDayRules) {
            // Get court/times booked by existing rules for the same day as the new
            // rule
            Set<ImmutablePair<Integer, Integer>> bookedCourts = new HashSet<>();
            addBookingRuleToSet(bookingRule, bookedCourts);
            if (Sets.intersection(newBookedCourts, bookedCourts).size() > 0) {
                sameDayOverlappingRules.add(bookingRule);
            }
        }
        logger.log(sameDayOverlappingRules.size()
                + " existing rules also apply to this day of the week and overlap the same Court/Time block");

        // Remove any non-recurring rules with a date before the new rule starts -
        // they cannot possibly clash.
        Set<BookingRule> activeSameDayOverlappingRules = new HashSet<>(sameDayOverlappingRules);
        Date newDate = (new SimpleDateFormat("yyyy-MM-dd")).parse(newBookingRule.getBooking().getDate());
        for (BookingRule bookingRule : sameDayOverlappingRules) {
            if ((!bookingRule.getIsRecurring()) && ((new SimpleDateFormat("yyyy-MM-dd"))
                    .parse(bookingRule.getBooking().getDate()).before(newDate))) {
                logger.log("Removing same-day overlapping rule as it does not clash: " + bookingRule);
                activeSameDayOverlappingRules.remove(bookingRule);
            }
        }

        // If the new rule is recurring and any non-recurring existing rules are
        // left, then we have a clash, unless it has a relevant exclusion. N.B. We
        // need to allow exclusions at rule creation time to support backup/restore
        // functionality.
        if (newBookingRule.getIsRecurring()) {
            if (activeSameDayOverlappingRules.stream().anyMatch(rule -> (!rule.getIsRecurring()
                    && !ArrayUtils.contains(newBookingRule.getDatesToExclude(), rule.getBooking().getDate())))) {
                // New rule have a relevant exclusion, so we have a clash.
                logger.log(
                        "Clash as there's an existing clashing non-recurring rule and new rule is recurring without a relevant exclusion");
                return true;
            }
        } else {
            // If the new rule is non-recurring and any non-recurring rules remain,
            // then we have a clash if both have the same date.
            if (activeSameDayOverlappingRules.stream()
                    .anyMatch(rule -> (rule.getBooking().getDate().equals(newBookingRule.getBooking().getDate()))
                            && (!rule.getIsRecurring()))) {
                logger.log(
                        "Clash as new rule is non-recurring and there's an existing clashing non-recurring rule");
                return true;
            }
        }

        // Finished with non-recurring existing rules - so ditch them.
        activeSameDayOverlappingRules.removeIf(rule -> !rule.getIsRecurring());

        // If new rule is non-recurring, we have a clash if any existing recurring
        // rules start before it, unless they have a relevant exclusion.
        if (!newBookingRule.getIsRecurring()) {
            Date newBookingRuleDate = (new SimpleDateFormat("yyyy-MM-dd"))
                    .parse(newBookingRule.getBooking().getDate());
            for (BookingRule bookingRule : activeSameDayOverlappingRules) {
                if ((new SimpleDateFormat("yyyy-MM-dd")).parse(bookingRule.getBooking().getDate())
                        .before(newBookingRuleDate)) {
                    // Does this rule have a relevant exclusion?
                    if (ArrayUtils.contains(bookingRule.getDatesToExclude(),
                            newBookingRule.getBooking().getDate())) {
                        logger.log("Recurring rule does not clash as it has a matching rule exclusion: "
                                + bookingRule.toString());
                        continue;
                    }
                    // We have a clash!
                    logger.log(
                            "Clash as new rule is non-recurring and there's an existing clashing recurring rule");
                    return true;
                }
            }
        } else {
            if (activeSameDayOverlappingRules.size() > 0) {
                // Always clash if both new and existing rules are recurring.
                logger.log("Clash as new rule is recurring and there's an existing clashing recurring rule");
                return true;
            }
        }

        logger.log("No clash!");
        return false;
    }

    private void addBookingRuleToSet(BookingRule bookingRule, Set<ImmutablePair<Integer, Integer>> bookedCourts) {
        for (int court = bookingRule.getBooking().getCourt(); court < bookingRule.getBooking().getCourt()
                + bookingRule.getBooking().getCourtSpan(); court++) {
            for (int slot = bookingRule.getBooking().getSlot(); slot < bookingRule.getBooking().getSlot()
                    + bookingRule.getBooking().getSlotSpan(); slot++) {
                bookedCourts.add(new ImmutablePair<>(court, slot));
            }
        }
    }

    private ImmutablePair<Optional<Integer>, Set<BookingRule>> getVersionedBookingRules() throws Exception {
        logger.log("About to get all versioned booking rules from simpledb");

        // Get existing booking rules (and version number), via consistent read:
        ImmutablePair<Optional<Integer>, Set<Attribute>> versionedAttributes = optimisticPersister
                .get(ruleItemName);

        // Convert attributes to BookingRules:
        Set<BookingRule> existingBookingRules = new HashSet<>();
        versionedAttributes.right.stream().forEach(attribute -> {
            existingBookingRules.add(getBookingRuleFromAttribute(attribute));
        });

        return new ImmutablePair<>(versionedAttributes.left, existingBookingRules);
    }

    private BookingRule getBookingRuleFromAttribute(Attribute attribute) {
        // N.B. BookingRule attributes have names like
        // <date>-<court>-<courtSpan>-<slot>-<slotSpan>-<isRecurring>-<name>
        // e.g. 2016-07-04-4-2-7-3-true-TeamTraining books courts 4-5 for time slots
        // 7-9 every Monday, starting on Monday 4th July 2016, for TeamTraining.
        // The value is a comma-separated array of dates to exclude.
        String[] parts = attribute.getName().split("-");
        String date = parts[0] + "-" + parts[1] + "-" + parts[2];
        Integer court = Integer.parseInt(parts[3]);
        Integer courtSpan = Integer.parseInt(parts[4]);
        Integer slot = Integer.parseInt(parts[5]);
        Integer slotSpan = Integer.parseInt(parts[6]);
        Boolean isRecurring = Boolean.valueOf(parts[7]);
        // All remaining parts will be the booking name - possibly hyphenated
        String name = "";
        for (int partNum = 8; partNum < parts.length; partNum++) {
            name += parts[partNum];
            if (partNum < (parts.length - 1)) {
                name += "-";
            }
        }
        Booking rulesBooking = new Booking(court, courtSpan, slot, slotSpan, name);
        rulesBooking.setDate(date);
        String[] datesToExclude = new String[0];
        if (attribute.getValue().length() > 0) {
            // Use split only if we have some dates to exclude - otherwise we would
            // get an (invalid) length-1 array containing a single empty string.
            datesToExclude = attribute.getValue().split(",");
        }
        return new BookingRule(rulesBooking, isRecurring, datesToExclude);
    }

    private String getAttributeNameFromBookingRule(BookingRule bookingRule) {
        return bookingRule.getBooking().getDate().toString() + "-" + bookingRule.getBooking().getCourt().toString()
                + "-" + bookingRule.getBooking().getCourtSpan().toString() + "-"
                + bookingRule.getBooking().getSlot().toString() + "-"
                + bookingRule.getBooking().getSlotSpan().toString() + "-" + bookingRule.getIsRecurring().toString()
                + "-" + bookingRule.getBooking().getName();
    }

    private void purgeExpiredRulesAndRuleExclusions() throws Exception {
        ImmutablePair<Optional<Integer>, Set<BookingRule>> versionedBookingRules = getVersionedBookingRules();
        Set<BookingRule> existingBookingRules = versionedBookingRules.right;

        Optional<Integer> versionNumber = versionedBookingRules.left;
        String todayFormatted = getCurrentLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        Date today = (new SimpleDateFormat("yyyy-MM-dd").parse(todayFormatted));
        logger.log("Purging all rules and exclusions that expired before: " + todayFormatted);
        for (BookingRule bookingRule : existingBookingRules) {
            if (!bookingRule.getIsRecurring()
                    && (new SimpleDateFormat("yyyy-MM-dd").parse(bookingRule.getBooking().getDate()))
                            .before(today)) {
                logger.log("Deleting non-recurring booking rule as it has expired: " + bookingRule.toString());
                try {
                    deleteRule(bookingRule, false);
                    logger.log("Deleted expired booking rule");
                } catch (Exception exception) {
                    // Don't want to abort here if we fail to remove a rule - after all
                    // we'll get another shot at it in 24 hours time.
                    logger.log("Exception caught deleting expired booking rule - swallowing and carrying on...");
                }
                continue;
            }

            // Purge any expired exclusions from this rule
            if (!bookingRule.getIsRecurring()) {
                // Non-recurring rules have no exclusions
                continue;
            }
            logger.log("Purging any expired exclusions from recurring rule: " + bookingRule);
            Set<String> datesToExclude = Sets.newHashSet(bookingRule.getDatesToExclude());
            Set<String> newDatesToExclude = new HashSet<>();
            for (String date : datesToExclude) {
                if ((new SimpleDateFormat("yyyy-MM-dd").parse(date)).after(new SimpleDateFormat("yyyy-MM-dd").parse(
                        getCurrentLocalDate().minusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))))) {
                    // Keep the exclusion if it applies to a date after yesterday.
                    newDatesToExclude.add(date);
                } else {
                    logger.log("Expiring exclusion for: " + date);
                }
                if (datesToExclude.size() > newDatesToExclude.size()) {
                    // Update the database as some exclusions have been purged
                    logger.log("Proceeding to update the rule after purging expired exclusion(s)");
                    String attributeName = getAttributeNameFromBookingRule(bookingRule);
                    String attributeValue = StringUtils.join(newDatesToExclude, ",");
                    logger.log("ItemName: " + ruleItemName);
                    logger.log("AttributeName: " + attributeName);
                    logger.log("AttributeValue: " + attributeValue);
                    ReplaceableAttribute bookingRuleAttribute = new ReplaceableAttribute();
                    bookingRuleAttribute.setName(attributeName);
                    bookingRuleAttribute.setValue(attributeValue);
                    bookingRuleAttribute.setReplace(true);
                    try {
                        versionNumber = Optional
                                .of(optimisticPersister.put(ruleItemName, versionNumber, bookingRuleAttribute));
                        logger.log("Updated rule to purge expired exclusion(s)");
                    } catch (Exception exception) {
                        // Don't want to abort here if we fail to remove an exclusion -
                        // after all we'll get another shot at it in 24 hours time.
                        logger.log(
                                "Exception caught deleting expired booking exclusion - swallowing and carrying on...");
                    }
                }
            }
        }
        logger.log("Purged all expired rules and exclusions");
    }

    /**
     * Returns an SNS client.
     *
     * <p>This method is provided so unit tests can mock out SNS.
     */
    protected AmazonSNS getSNSClient() {

        // Use a getter here so unit tests can substitute a mock client
        AmazonSNS client = AmazonSNSClientBuilder.standard().withRegion(region.getName()).build();
        return client;
    }

    /**
     * Returns an optimistic persister.
     * @throws IOException 
     */
    protected IOptimisticPersister getOptimisticPersister() throws IOException {
        // Use a getter here so unit tests can substitute a mock persister
        return new OptimisticPersister();
    }

    /**
     * Returns the current London local date.
     */
    protected LocalDate getCurrentLocalDate() {
        // Use a getter here so unit tests can substitute a different date.

        // This gets the correct local date no matter what the user's device
        // system time may say it is, and no matter where in AWS we run.
        return BookingsUtilities.getCurrentLocalDate();
    }

    /**
     * Returns a named environment variable.
     * @throws Exception 
     */
    protected String getEnvironmentVariable(String variableName) throws Exception {
        // Use a getter here so unit tests can substitute a mock value.
        // We get the value from an environment variable so that CloudFormation can
        // set the actual value when the stack is created.

        String environmentVariable = System.getenv(variableName);
        if (environmentVariable == null) {
            logger.log("Environment variable: " + variableName + " is not defined, so throwing.");
            throw new Exception("Environment variable: " + variableName + " should be defined.");
        }
        return environmentVariable;
    }
}