com.smoketurner.notification.application.store.NotificationStore Maven / Gradle / Ivy
/**
* Copyright 2016 Smoke Turner, LLC.
*
* 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 com.smoketurner.notification.application.store;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.concurrent.ExecutionException;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.Nonnull;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.basho.riak.client.api.RiakClient;
import com.basho.riak.client.api.cap.UnresolvedConflictException;
import com.basho.riak.client.api.commands.buckets.StoreBucketProperties;
import com.basho.riak.client.api.commands.kv.DeleteValue;
import com.basho.riak.client.api.commands.kv.FetchValue;
import com.basho.riak.client.api.commands.kv.StoreValue;
import com.basho.riak.client.api.commands.kv.UpdateValue;
import com.basho.riak.client.core.query.Location;
import com.basho.riak.client.core.query.Namespace;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.smoketurner.notification.api.Notification;
import com.smoketurner.notification.api.Rule;
import com.smoketurner.notification.application.core.IdGenerator;
import com.smoketurner.notification.application.core.Rollup;
import com.smoketurner.notification.application.core.UserNotifications;
import com.smoketurner.notification.application.exceptions.NotificationStoreException;
import com.smoketurner.notification.application.riak.NotificationListAddition;
import com.smoketurner.notification.application.riak.NotificationListDeletion;
import com.smoketurner.notification.application.riak.NotificationListObject;
public class NotificationStore {
private static final Logger LOGGER = LoggerFactory
.getLogger(NotificationStore.class);
public static final String CURSOR_NAME = "notifications";
private static final Namespace NAMESPACE = new Namespace("notifications");
private final RiakClient client;
private final IdGenerator idGenerator;
private final CursorStore cursors;
private final RuleStore ruleStore;
// timers
private final Timer fetchTimer;
private final Timer updateTimer;
private final Timer deleteTimer;
/**
* Constructor
*
* @param client
* Riak client
* @param idGenerator
* ID Generator
* @param cursors
* Cursor data store
* @param rules
* Rule data store
*/
public NotificationStore(@Nonnull final RiakClient client,
@Nonnull final IdGenerator idGenerator,
@Nonnull final CursorStore cursors,
@Nonnull final RuleStore ruleStore) {
final MetricRegistry registry = SharedMetricRegistries
.getOrCreate("default");
this.fetchTimer = registry
.timer(MetricRegistry.name(NotificationStore.class, "fetch"));
this.updateTimer = registry
.timer(MetricRegistry.name(NotificationStore.class, "store"));
this.deleteTimer = registry
.timer(MetricRegistry.name(NotificationStore.class, "delete"));
this.client = Objects.requireNonNull(client);
this.idGenerator = Objects.requireNonNull(idGenerator);
this.cursors = Objects.requireNonNull(cursors);
this.ruleStore = Objects.requireNonNull(ruleStore);
}
/**
* Internal method to set the allow_multi to true
*/
public void initialize() {
final boolean allowMulti = true;
LOGGER.debug("Setting allow_multi={} for namespace={}", allowMulti,
NAMESPACE);
final StoreBucketProperties storeBucketProperties = new StoreBucketProperties.Builder(
NAMESPACE).withAllowMulti(allowMulti).build();
try {
client.execute(storeBucketProperties);
} catch (InterruptedException e) {
LOGGER.warn(String.format(
"Unable to set allow_multi=%s for namespace=%s", allowMulti,
NAMESPACE), e);
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
LOGGER.error(String.format(
"Unable to set allow_multi=%s for namespace=%s", allowMulti,
NAMESPACE), e);
}
}
/**
* Fetch a list of notifications for a given user
*
* @param username
* User to fetch notifications for
* @return Optional list of notifications or absent
* @throws NotificationStoreException
* if unable to fetch the notifications
*/
public Optional fetch(@Nonnull final String username)
throws NotificationStoreException {
Objects.requireNonNull(username);
Preconditions.checkArgument(!username.isEmpty(),
"username cannot be empty");
final Location location = new Location(NAMESPACE, username);
LOGGER.debug("Fetching key: {}", location);
final NotificationListObject list;
final FetchValue fv = new FetchValue.Builder(location).build();
try (Timer.Context context = fetchTimer.time()) {
final FetchValue.Response response = client.execute(fv);
list = response.getValue(NotificationListObject.class);
} catch (UnresolvedConflictException e) {
LOGGER.error("Unable to resolve siblings for key: " + location, e);
throw new NotificationStoreException(e);
} catch (ExecutionException e) {
LOGGER.error("Unable to fetch key: " + location, e);
throw new NotificationStoreException(e);
} catch (InterruptedException e) {
LOGGER.warn("Interrupted fetching key: " + location, e);
Thread.currentThread().interrupt();
throw new NotificationStoreException(e);
}
if (list == null) {
return Optional.absent();
}
return Optional
.of(splitNotifications(username, list.getNotifications()));
}
/**
* Sets the unseen state on all of the notifications based any previously
* viewed notifications.
*
* @param username
* Username of the notifications
* @param notifications
* Original notifications list
* @return the seen and unseen notifications
* @throws NotificationStoreException
* if unable to update the cursor
*/
public UserNotifications splitNotifications(@Nonnull final String username,
@Nonnull final SortedSet notifications)
throws NotificationStoreException {
Objects.requireNonNull(username);
Preconditions.checkArgument(!username.isEmpty(),
"username cannot be empty");
// if there are no notifications, just return
if (notifications == null || notifications.isEmpty()) {
return new UserNotifications();
}
// get the ID of the most recent notification (this should never be
// zero)
final long newestId = notifications.first().getId(0L);
LOGGER.debug("Newest notification ID: {}", newestId);
// fetch rules from cache
final Map rules = ruleStore.fetchCached();
LOGGER.debug("Fetched {} rules from cache", rules.size());
final Rollup unseenRollup = new Rollup(rules);
final Optional cursor = cursors.fetch(username, CURSOR_NAME);
if (!cursor.isPresent()) {
LOGGER.debug("User ({}) has no cursor", username);
// if the user has no cursor, update the cursor to the newest
// notification
LOGGER.debug("Updating cursor to {}", newestId);
cursors.store(username, CURSOR_NAME, newestId);
// set all of the notifications to unseen=true
return new UserNotifications(
unseenRollup.rollup(setUnseenState(notifications, true)));
}
final long lastSeenId = cursor.or(0L);
LOGGER.debug("Last seen notification ID: {}", lastSeenId);
// if the latest seen notification ID is less than the newest
// notification ID, then update the cursor to the newest notification
// ID.
if (lastSeenId < newestId) {
LOGGER.debug("Updating cursor to {}", newestId);
cursors.store(username, CURSOR_NAME, newestId);
}
// get the parent ID of the last seen notification ID
final Optional lastNotification = tryFind(notifications,
lastSeenId);
if (!lastNotification.isPresent()) {
// if the last notification is not found, set all of the
// notifications as unseen
return new UserNotifications(
unseenRollup.rollup(setUnseenState(notifications, true)));
}
// Set the head of the list as being unseen
final Stream unseen = setUnseenState(
notifications.headSet(lastNotification.get()), true);
// Set the tail of the list as being seen
final Stream seen = setUnseenState(
notifications.tailSet(lastNotification.get()), false);
final Rollup seenRollup = new Rollup(rules);
return new UserNotifications(unseenRollup.rollup(unseen),
seenRollup.rollup(seen));
}
/**
* Store a new notification for a user
*
* @param username
* User to store the notification
* @param notification
* Notification to store
* @return the stored notification
* @throws NotificationStoreException
* if unable to store the notification
*/
public Notification store(@Nonnull final String username,
@Nonnull final Notification notification)
throws NotificationStoreException {
Objects.requireNonNull(username);
Preconditions.checkArgument(!username.isEmpty(),
"username cannot be empty");
Objects.requireNonNull(notification);
final Notification updatedNotification = Notification
.builder(notification).withId(idGenerator.nextId())
.withCreatedAt(now()).build();
final NotificationListAddition update = new NotificationListAddition(
updatedNotification);
final Location location = new Location(NAMESPACE, username);
final UpdateValue updateValue = new UpdateValue.Builder(location)
.withUpdate(update)
.withStoreOption(StoreValue.Option.RETURN_BODY, false).build();
LOGGER.debug("Updating key: {}", location);
try (Timer.Context context = updateTimer.time()) {
client.execute(updateValue);
} catch (ExecutionException e) {
LOGGER.error("Unable to update key: " + location, e);
throw new NotificationStoreException(e);
} catch (InterruptedException e) {
LOGGER.warn("Update request was interrupted", e);
Thread.currentThread().interrupt();
throw new NotificationStoreException(e);
}
return updatedNotification;
}
/**
* Asynchronously delete all of the notifications for a given user
*
* @param username
* User to delete all the notifications
*/
public void removeAll(@Nonnull final String username) {
Objects.requireNonNull(username);
Preconditions.checkArgument(!username.isEmpty(),
"username cannot be empty");
final Location location = new Location(NAMESPACE, username);
final DeleteValue deleteValue = new DeleteValue.Builder(location)
.build();
LOGGER.debug("Deleting key (async): {}", location);
try (Timer.Context context = deleteTimer.time()) {
client.executeAsync(deleteValue);
}
cursors.delete(username, CURSOR_NAME);
}
/**
* Asynchronously remove individual notifications for a given user
*
* @param username
* User to remove notifications from
* @param ids
* Notification IDs to remove
*/
public void remove(@Nonnull final String username,
@Nonnull final Collection ids) {
Objects.requireNonNull(username);
Preconditions.checkArgument(!username.isEmpty(),
"username cannot be empty");
Objects.requireNonNull(ids);
// if nothing to remove, return early
if (ids.isEmpty()) {
return;
}
final Location location = new Location(NAMESPACE, username);
final NotificationListDeletion delete = new NotificationListDeletion(
ids);
final UpdateValue updateValue = new UpdateValue.Builder(location)
.withUpdate(delete)
.withStoreOption(StoreValue.Option.RETURN_BODY, false).build();
LOGGER.debug("Updating key (async): {}", location);
try (Timer.Context context = updateTimer.time()) {
client.executeAsync(updateValue);
}
}
/**
* Set the unseen state on multiple notifications
*
* @param notifications
* notifications to update
* @param unseen
* whether the notifications have been seen or not
* @return the updated notifications
*/
public static Stream setUnseenState(
@Nonnull final Iterable notifications,
final boolean unseen) {
return StreamSupport.stream(notifications.spliterator(), false)
.map(notification -> Notification.builder(notification)
.withUnseen(unseen).build());
}
/**
* Return the parent notification that matches the given ID or is the parent
* of a child notification.
*
* @param notifications
* Notifications to search through
* @param id
* Notification ID to find
* @return the notification
*/
public static Optional tryFind(
@Nonnull final Iterable notifications,
final long id) {
return Iterables.tryFind(notifications, notification -> {
// first check that the ID matches
final Optional notificationId = notification.getId();
if (!notificationId.isPresent()) {
return false;
} else if (notificationId.get() == id) {
return true;
}
// Check to see if the notification is included in any rolled
// up notifications. This code should not be hit as tryFind() is
// called prior to the rollups happening, but we include this here
// for completeness.
final Collection children = notification
.getNotifications().or(Collections.emptyList());
if (children.isEmpty()) {
return false;
}
return (tryFind(children, id)).isPresent();
});
}
/**
* Returns the index in notifications that matches the given ID or is the
* parent of a child notification, or -1 if the notification was not found.
*
* @param notifications
* Notifications to search through
* @param id
* Notification ID to find
* @return the position of the notification or -1 if not found
*/
public static int indexOf(
@Nonnull final Iterable notifications,
final long id) {
return Iterables.indexOf(notifications, notification -> {
// first check that the ID matches
final Optional notificationId = notification.getId();
if (!notificationId.isPresent()) {
return false;
} else if (notificationId.get() == id) {
return true;
}
// then check to see if the notification is included in any rolled
// up notifications
final Collection children = notification
.getNotifications().or(Collections.emptyList());
if (children.isEmpty()) {
return false;
}
return indexOf(children, id) != -1;
});
}
/**
* Returns an iterable that skips forward to a given notification ID then
* only returns count more notifications. If the given notification ID is
* not found
*
* @param notifications
* Iterable of notifications
* @param startId
* notification ID to start at
* @param inclusive
* Whether to include the startId notification or not
* @param limitSize
* Number of notifications to return
* @return Iterable containing the subset of the original notifications
*/
public Iterable skip(
@Nonnull final Iterable notifications,
final long startId, final boolean inclusive, final int limitSize) {
Objects.requireNonNull(notifications);
final int position = indexOf(notifications, startId);
if (position == -1) {
return Iterables.limit(notifications, limitSize);
}
if (inclusive) {
return Iterables.limit(Iterables.skip(notifications, position),
limitSize);
}
return Iterables.limit(Iterables.skip(notifications, position + 1),
limitSize);
}
/**
* Return the current date time (overridden in tests)
*
* @return the current date time
*/
@VisibleForTesting
public DateTime now() {
return DateTime.now(DateTimeZone.UTC);
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy