package com.collabinate.server.engine;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.PriorityQueue;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.collabinate.server.activitystreams.Activity;
import com.collabinate.server.activitystreams.ActivityStreamsCollection;
import com.collabinate.server.activitystreams.ActivityStreamsObject;
import com.google.common.base.Joiner;
import com.tinkerpop.blueprints.Direction;
import com.tinkerpop.blueprints.Edge;
import com.tinkerpop.blueprints.KeyIndexableGraph;
import com.tinkerpop.blueprints.Vertex;
import com.tinkerpop.blueprints.util.wrappers.partition.PartitionGraph;
/**
* An implementation of both reader and writer backed by a graph database.
*
* @author mafuba
*
*/
public class GraphEngine implements CollabinateReader, CollabinateWriter
{
/**
* Logger instance.
*/
private final Logger logger = LoggerFactory.getLogger(GraphEngine.class);
/**
* The graph database backing this instance.
*/
private CollabinateGraph graph;
// Comparators
private ActivityDateComparator activityDateComparator =
new ActivityDateComparator();
private EntityFirstActivityDateComparator firstActivityDateComparator =
new EntityFirstActivityDateComparator();
/**
* Ensures that the graph can have IDs assigned.
*
* @param graph A Tinkerpop BluePrints graph to act as the store for the
* server.
*/
public GraphEngine(final CollabinateGraph graph)
{
if (null == graph)
{
throw new IllegalArgumentException("graph must not be null");
}
this.graph = graph;
}
@Override
public void addActivity(String tenantId, String entityId, Activity activity)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
if (null == activity)
{
throw new IllegalArgumentException("activity must not be null");
}
Vertex entityVertex = getOrCreateEntityVertex(tenantId, entityId);
Vertex activityVertex = serializeActivity(activity, tenantId, entityId);
// if the inserted activity is first in its stream, it may have
// changed the entity order for feed paths
boolean updateOrder = insertActivity(entityVertex, activityVertex);
adjustNumericProperty(entityVertex, STRING_STREAM_COUNT, 1);
updateFeed(tenantId, entityId, entityVertex, 1, updateOrder);
graph.commit();
}
@Override
public Activity getActivity(String tenantId, String entityId,
String activityId)
{
Activity activity = null;
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null != activityVertex)
{
activity = new Activity(
(String)activityVertex.getProperty(STRING_CONTENT));
}
graph.commit();
return activity;
}
@Override
public ActivityStreamsObject getComment(String tenantId, String entityId,
String activityId, String commentId)
{
ActivityStreamsObject comment = null;
Vertex commentVertex =
getCommentVertex(tenantId, entityId, activityId, commentId);
if (null != commentVertex)
{
comment = new ActivityStreamsObject(
(String)commentVertex.getProperty(STRING_CONTENT));
}
graph.commit();
return comment;
}
/**
* Attempts to retrieve the vertex for the entity with the given ID. If a
* matching entity cannot be found, the vertex is created.
*
* @param tenantId The ID of the tenant to add to the vertex upon creation.
* @param entityId The ID of the entity to add to the vertex upon creation.
* @return The vertex for the given entity.
*/
private synchronized Vertex getOrCreateEntityVertex(final String tenantId,
final String entityId)
{
Vertex entityVertex = graph.getVertex(
tenantId + STRING_ID_SEPARATOR + entityId);
if (null == entityVertex)
{
entityVertex = graph.addVertex(
tenantId + STRING_ID_SEPARATOR + entityId);
entityVertex.setProperty(STRING_TENANT_ID, tenantId);
entityVertex.setProperty(STRING_ENTITY_ID, entityId);
entityVertex.setProperty(STRING_TYPE, STRING_ENTITY);
entityVertex.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
entityVertex.setProperty(STRING_STREAM_COUNT, 0);
entityVertex.setProperty(STRING_FEED_COUNT, 0);
entityVertex.setProperty(STRING_FOLLOWING_COUNT, 0);
entityVertex.setProperty(STRING_FOLLOWER_COUNT, 0);
graph.commit();
}
return entityVertex;
}
/**
* Retrieves a single activity vertex that matches the given parameters, or
* null if none match.
*
* @param tenantId the tenant for which the request is processed.
* @param entityId the ID of the entity for which to retrieve an activity.
* @param activityId the ID of the activity to retrieve.
* @return A vertex for the given activity, or null.
*/
private Vertex getActivityVertex(String tenantId, String entityId,
String activityId)
{
return graph.getVertex(tenantId + STRING_ID_SEPARATOR + entityId
+ STRING_ID_SEPARATOR + activityId);
}
/**
* Retrieves a single comment vertex that matches the given parameters, or
* null if none match.
*
* @param tenantId the tenant for which the request is processed.
* @param entityId the ID of the entity for which to retrieve an comment.
* @param activityId the ID of the activity for which to retrieve a comment.
* @param commentId the ID of the comment to retrieve.
* @return A vertex for the given comment, or null.
*/
private Vertex getCommentVertex(String tenantId, String entityId,
String activityId, String commentId)
{
return graph.getVertex(tenantId + STRING_ID_SEPARATOR + entityId
+ STRING_ID_SEPARATOR + activityId
+ STRING_ID_SEPARATOR + commentId);
}
/**
* Creates a new vertex representation of a given activity.
*
* @param activity The activity to be represented.
* @param tenantId The tenant for the activity.
* @param entityId The entity to which the activity belongs.
* @return A vertex that represents the given activity.
*/
private Vertex serializeActivity(final Activity activity,
final String tenantId, final String entityId)
{
Vertex activityVertex = graph.addVertex(tenantId + STRING_ID_SEPARATOR
+ entityId + STRING_ID_SEPARATOR + activity.getId());
activityVertex.setProperty(STRING_TENANT_ID, tenantId);
activityVertex.setProperty(STRING_ENTITY_ID, entityId);
activityVertex.setProperty(STRING_ACTIVITY_ID, activity.getId());
activityVertex.setProperty(STRING_TYPE, STRING_ACTIVITY);
activityVertex.setProperty(STRING_SORTTIME,
activity.getSortTime().toString());
activityVertex.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
activityVertex.setProperty(STRING_CONTENT, activity.toString());
activityVertex.setProperty(STRING_COMMENT_COUNT, 0);
activityVertex.setProperty(STRING_LIKE_COUNT, 0);
return activityVertex;
}
/**
* Creates a new vertex representation of a given comment.
*
* @param comment The comment to be represented.
* @param tenantId The tenant for the comment.
* @param entityId The entity to which the activity for the comment belongs.
* @param activityId The activity to which the comment belongs.
* @return A vertex that represents the given comment.
*/
private Vertex serializeComment(final ActivityStreamsObject comment,
final String tenantId, final String entityId,
final String activityId)
{
DateTime currentDate = DateTime.now(DateTimeZone.UTC);
if (null == comment.getSortTime())
{
comment.setPublished(currentDate);
}
if (comment.getId() == null || comment.getId().trim().isEmpty())
{
comment.setId(ActivityStreamsObject.generateUuidUrn());
}
Vertex commentVertex = graph.addVertex(tenantId + STRING_ID_SEPARATOR
+ entityId + STRING_ID_SEPARATOR
+ activityId + STRING_ID_SEPARATOR
+ comment.getId());
commentVertex.setProperty(STRING_TENANT_ID, tenantId);
commentVertex.setProperty(STRING_ENTITY_ID, entityId);
commentVertex.setProperty(STRING_ACTIVITY_ID, activityId);
commentVertex.setProperty(STRING_COMMENT_ID, comment.getId());
commentVertex.setProperty(STRING_TYPE, STRING_COMMENT);
commentVertex.setProperty(STRING_SORTTIME,
comment.getSortTime().toString());
commentVertex.setProperty(STRING_CREATED, currentDate.toString());
commentVertex.setProperty(STRING_CONTENT, comment.toString());
return commentVertex;
}
/**
* Adds an activity vertex at the correct chronological location among the
* stream vertices of an entity.
*
* @param entity The vertex representing the entity.
* @param addedActivity The activity to add to the stream.
* @return true if the added activity is the newest (first) in the stream,
* otherwise false.
*/
private boolean insertActivity(final Vertex entity,
final Vertex addedActivity)
{
if (null == entity)
{
throw new IllegalArgumentException("entity must not be null");
}
if (null == addedActivity)
{
throw new IllegalArgumentException(
"addedActivity must not be null");
}
Edge currentStreamEdge = getStreamEdge(entity);
Vertex currentActivity = getNextActivity(entity);
Vertex previousActivity = entity;
int position = 0;
// advance along the stream path, comparing each activity to the new one
while (currentActivity != null && activityDateComparator
.compare(addedActivity, currentActivity) > 0)
{
previousActivity = currentActivity;
currentStreamEdge = getStreamEdge(currentActivity);
currentActivity = getNextActivity(currentActivity);
position++;
}
String tenantId = (String)entity.getProperty(STRING_TENANT_ID);
String entityId = (String)entity.getProperty(STRING_ENTITY_ID);
// add a stream edge between the previous activity (the one that is
// newer than the added one, or the entity if there are none) and the
// new one.
Edge newEdge = previousActivity.addEdge(STRING_STREAM, addedActivity);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
// if there are one or more activities that are older than the added
// one, add an edge between the added one and the next older one, and
// delete the edge between that one and the previous (next newer) one.
if (null != currentStreamEdge)
{
newEdge = addedActivity.addEdge(STRING_STREAM, currentActivity);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
currentStreamEdge.remove();
}
return position == 0;
}
/**
* Adds a comment vertex at the correct chronological location among the
* comment vertices of an activity.
*
* @param activity The vertex representing the activity.
* @param addedComment The comment to add to the activity.
* @param userId The user to associate with the comment. May be null.
*/
private void insertComment(final Vertex activity,
final Vertex addedComment, final String userId)
{
Edge currentCommentEdge = getCommentEdge(activity);
Vertex currentComment = getNextComment(activity);
Vertex previousComment = activity;
// advance along the comment path, comparing each comment to the new one
while (currentComment != null && activityDateComparator
.compare(addedComment, currentComment) > 0)
{
previousComment = currentComment;
currentCommentEdge = getCommentEdge(currentComment);
currentComment = getNextComment(currentComment);
}
String tenantId = (String)activity.getProperty(STRING_TENANT_ID);
String entityId = (String)activity.getProperty(STRING_ENTITY_ID);
String activityId = (String)activity.getProperty(STRING_ACTIVITY_ID);
// add a comment edge between the previous comment (the one that is
// newer than the added one, or the activity if there are none) and the
// new one.
Edge newEdge = previousComment.addEdge(STRING_COMMENTS, addedComment);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_ACTIVITY_ID, activityId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
// if there are one or more comments that are older than the added one,
// add an edge between the added one and the next older one, and delete
// the edge between that one and the previous (next newer) one.
if (null != currentCommentEdge)
{
newEdge = addedComment.addEdge(STRING_COMMENTS, currentComment);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_ACTIVITY_ID, activityId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
currentCommentEdge.remove();
}
// if provided, create an edge relating the user to the comment
if (null != userId && !userId.equals(""))
{
newEdge = getOrCreateEntityVertex(tenantId, userId)
.addEdge(STRING_COMMENTED, addedComment);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, userId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
}
}
/**
* Retrieves the edge to the next activity from the given vertex, whether
* the given vertex is an entity or a activity.
*
* @param node An activity or entity for which to find the next stream edge.
* @return The next stream edge in the stream containing or starting at
* the given vertex.
*/
private Edge getStreamEdge(Vertex node)
{
return getSingleOutgoingEdge(node, STRING_STREAM);
}
/**
* Retrieves the edge to the next overlay from the given vertex, whether
* the given vertex is an entity or an overlay.
*
* @param node An overlay or entity for which to find the next feed edge.
* @return The next feed edge in the feed containing or starting at the
* given vertex.
*/
private Edge getFeedEdge(Vertex node)
{
return getSingleOutgoingEdge(node, STRING_FEED);
}
/**
* Retrieves the edge to the next comment from the given vertex, whether
* the given vertex is an activity or a comment.
*
* @param node A comment or activity for which to find the next comment
* edge.
* @return The next comment edge in the comments containing or starting at
* the given vertex.
*/
private Edge getCommentEdge(Vertex node)
{
return getSingleOutgoingEdge(node, STRING_COMMENTS);
}
/**
* Retrieves the entity vertex pointed to by an overlay.
*
* @param overlay The overlay vertex for which to find the feed entity.
* @return The entity vertex for the given overlay vertex.
*/
private Vertex getFeedEntity(Vertex overlay)
{
if (null == overlay)
return null;
Iterator<Vertex> vertices =
overlay.getVertices(Direction.OUT, STRING_FEED_ENTITY)
.iterator();
Vertex entity = vertices.hasNext() ? vertices.next() : null;
if (null != entity)
{
if (vertices.hasNext())
{
logger.error("Multiple feed entities for overlay with id: {}",
overlay.getId());
}
}
return entity;
}
/**
* Retrieves the overlay pointing to the given entity vertex.
*
* @param entity The entity vertex for which to find the overlay.
* @param userId The entityId of the user for which to find the overlay.
* @return The overlay vertex for the given entity vertex.
*/
private Vertex getOverlayForEntity(Vertex entity, String userId)
{
Iterator<Vertex> vertices =
entity.getVertices(Direction.IN, STRING_FEED_ENTITY)
.iterator();
Vertex overlay;
while(vertices.hasNext())
{
overlay = vertices.next();
if (userId.equals(overlay.getProperty(STRING_ENTITY_ID)))
return overlay;
}
return null;
}
/**
* Retrieves the single edge emanating from the given vertex where the edge
* has the given label.
*
* @param node The vertex for which to find the labeled edge.
* @param edgeLabel The label of the edge to find.
* @return The edge with the given label emanating from the given vertex, or
* null if the node is null or has no such edge.
*/
private Edge getSingleOutgoingEdge(Vertex node, String edgeLabel)
{
if (null == node)
return null;
Iterator<Edge> edges =
node.getEdges(Direction.OUT, edgeLabel).iterator();
Edge edge = edges.hasNext() ? edges.next() : null;
if (null != edge)
{
if (edges.hasNext())
{
logger.error("Multiple outgoing edges with label: \"{}\" " +
" for vertex: {}", edgeLabel, node.getId());
}
}
return edge;
}
/**
* Adjusts the feed count for each following user. Also, if updateOrder is
* true, puts an entity into the correct chronological order in the feed
* paths of all the users that follow it. The is used when the first
* activity of an entity changes, which potentially changes its feed order.
*
* @param tenantId The ID of the tenant.
* @param entityId The raw ID of the entity.
* @param entity The entity for which followers are updated.
* @param activityChange The number of activities that were added or removed
* from the stream of the entity.
* @param
*/
private void updateFeed(String tenantId, String entityId, Vertex entity,
int activityChange, boolean updateOrder)
{
// get all the users that follow the entity
Iterable<Vertex> usersInGraph =
entity.getVertices(Direction.IN, STRING_FOLLOWS);
// copy the users to a separate list to prevent the collection
// underlying the iterable getting modified during processing,
// and adjust the feed counts while copying
ArrayList<Vertex> users = new ArrayList<Vertex>();
for (Vertex user : usersInGraph)
{
users.add(user);
adjustNumericProperty(user, STRING_FEED_COUNT, activityChange);
}
if (updateOrder)
{
// loop over each user and move the entity to the correct
// feed position by un-following and re-following
// TODO: is this the best way to do this?
String userId;
for (Vertex user : users)
{
userId = user.getProperty(STRING_ENTITY_ID);
DateTime followed = unfollowEntity(tenantId, userId, entityId);
followEntity(tenantId, userId, entityId, followed);
}
}
}
/**
* Increments or decrements the given numeric property of the given vertex
* by the given amount.
*
* @param vertex
* @param property
* @param amount
*/
private void adjustNumericProperty(Vertex vertex, String property,
int amount)
{
int value = (int)vertex.getProperty(property);
value = value + amount;
vertex.setProperty(property, value);
}
@Override
public void deleteActivity(String tenantId, String entityId,
String activityId)
{
if (null == tenantId)
throw new IllegalArgumentException("tenantId must not be null");
if (null == entityId)
throw new IllegalArgumentException("entityId must not be null");
if (null == activityId)
throw new IllegalArgumentException("activityId must not be null");
Vertex activityVertex = getActivityVertex(tenantId, entityId,
activityId);
if (null != activityVertex)
{
Vertex entityVertex = getOrCreateEntityVertex(tenantId, entityId);
boolean firstInStream = false;
if (activityId.equals((String)getNextActivity(entityVertex)
.getProperty(STRING_ACTIVITY_ID)))
{
// if the deleted activity was first in its stream, it may have
// changed the entity order for feed paths
firstInStream = true;
}
removeComments(activityVertex);
removeActivity(activityVertex);
adjustNumericProperty(entityVertex, STRING_STREAM_COUNT, -1);
updateFeed(tenantId, entityId, entityVertex, -1, firstInStream);
}
graph.commit();
}
/**
* Removes all of the comments from the given activity.
*
* @param activityVertex The activity for which to remove comments.
*/
private void removeComments(Vertex activityVertex)
{
for (Vertex comment : getCommentVertices(
activityVertex, 0, Integer.MAX_VALUE))
{
comment.remove();
}
}
/**
* Deletes the given activity vertex from its stream. The continuity of the
* stream is maintained.
*
* @param activityVertex The vertex representing the activity.
*/
private void removeActivity(Vertex activityVertex)
{
Vertex followingActivity = getNextActivity(activityVertex);
Vertex previousActivity = getPreviousActivity(activityVertex);
activityVertex.remove();
if (null != followingActivity)
{
Edge newEdge = previousActivity.addEdge(
STRING_STREAM, followingActivity);
newEdge.setProperty(STRING_TENANT_ID, previousActivity
.getProperty(STRING_TENANT_ID));
newEdge.setProperty(STRING_ENTITY_ID, previousActivity
.getProperty(STRING_ENTITY_ID));
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
}
activityVertex = null;
}
/**
* Deletes the given comment vertex. The continuity of the comments is
* maintained.
*
* @param commentVertex The vertex representing the comment.
*/
private void removeComment(Vertex commentVertex)
{
Vertex followingComment = getNextComment(commentVertex);
Vertex previousComment = getPreviousComment(commentVertex);
commentVertex.remove();
if (null != followingComment)
{
Edge newEdge = previousComment.addEdge(
STRING_COMMENTS, followingComment);
newEdge.setProperty(STRING_TENANT_ID, previousComment
.getProperty(STRING_TENANT_ID));
newEdge.setProperty(STRING_ENTITY_ID, previousComment
.getProperty(STRING_ENTITY_ID));
newEdge.setProperty(STRING_ACTIVITY_ID, previousComment
.getProperty(STRING_ACTIVITY_ID));
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
}
commentVertex = null;
}
@Override
public ActivityStreamsCollection getStream(String tenantId, String entityId,
int startIndex, int activitiesToReturn)
{
// since we need to advance from the beginning of the stream,
// this lets us keep track of where we are
int streamPosition = 0;
// once we reach the number of activities to return, we can stop
int foundActivityCount = 0;
List<Vertex> activityVertices = new ArrayList<Vertex>();
Vertex entityVertex = getOrCreateEntityVertex(tenantId, entityId);
Vertex currentActivity = getNextActivity(entityVertex);
// advance along the stream, collecting vertices after we get to the
// start index, and stopping when we have enough to return or run out
// of stream
while (null != currentActivity &&
foundActivityCount < activitiesToReturn)
{
if (streamPosition >= startIndex)
{
activityVertices.add(currentActivity);
foundActivityCount++;
}
currentActivity = getNextActivity(currentActivity);
streamPosition++;
}
graph.commit();
// we only have the vertices, the actual activities need to be created
return createCollection(activityVertices,
(int)entityVertex.getProperty(STRING_STREAM_COUNT));
}
/**
* Retrieves the activity after the given node by following the outgoing
* stream edge. The node can be an entity (including users) or an activity.
*
* @param node The entity or activity for which to find the next activity.
* @return The next activity after the given node, or null if one does not
* exist.
*/
private Vertex getNextActivity(Vertex node)
{
Edge streamEdge = getStreamEdge(node);
return null == streamEdge ? null : streamEdge.getVertex(Direction.IN);
}
private Vertex getPreviousActivity(Vertex node)
{
if (null == node)
return null;
Iterator<Vertex> vertices =
node.getVertices(Direction.IN, STRING_STREAM).iterator();
Vertex activity = vertices.hasNext() ? vertices.next() : null;
if (null != activity)
{
if (vertices.hasNext())
{
logger.error(
"Multiple previous activities for node with id: {}",
node.getId());
}
}
return activity;
}
/**
* Turns a collection of activity streams object containing vertices into an
* ActivityStreamsCollection.
*
* @param activityVertices The vertices to deserialize.
* @param totalItems The total logical number of items for the collection,
* which may be more than the number of vertices passed.
* @return An ActivityStreamsCollection that contains the objects in the
* given vertices.
*/
private ActivityStreamsCollection createCollection(
Collection<Vertex> vertices, int totalItems)
{
StringBuilder sb = new StringBuilder("{\"totalItems\":");
sb.append(totalItems);
sb.append(",\"items\":[");
sb.append(Joiner.on(",").join(getActivityStreamsContent(vertices)));
sb.append("]}");
return new ActivityStreamsCollection(sb.toString());
}
/**
* Extracts the Activity Streams formatted content from a set of vertices.
*
* @param vertices The vertices from which to extract the content.
* @return A collection of the string contents of the activities.
*/
private List<String> getActivityStreamsContent(Collection<Vertex> vertices)
{
List<String> content = new ArrayList<String>();
for (Vertex vertex : vertices)
{
content.add((String)vertex.getProperty(STRING_CONTENT));
}
return content;
}
@Override
public DateTime followEntity(String tenantId, String userId,
String entityId, DateTime followed)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == userId)
{
throw new IllegalArgumentException("userId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
if (null == followed)
{
followed = DateTime.now(DateTimeZone.UTC);
}
Vertex user = getOrCreateEntityVertex(tenantId, userId);
Vertex entity = getOrCreateEntityVertex(tenantId, entityId);
for (Edge edge : user.getEdges(Direction.OUT, STRING_FOLLOWS))
{
if (edge.getVertex(Direction.IN).getId().equals(entity.getId()))
{
DateTime existingDateTime =
DateTime.parse((String)edge
.getProperty(STRING_CREATED));
graph.commit();
return existingDateTime;
}
}
logger.debug("No follow relationship found for userID: {} " +
"to entityID: {}. Creating.", userId, entityId);
Edge followEdge = user.addEdge(STRING_FOLLOWS, entity);
followEdge.setProperty(STRING_TENANT_ID, tenantId);
followEdge.setProperty(STRING_CREATED, followed.toString());
insertFeedEntity(user, entity, tenantId);
adjustNumericProperty(user, STRING_FEED_COUNT,
(int)entity.getProperty(STRING_STREAM_COUNT));
adjustNumericProperty(user, STRING_FOLLOWING_COUNT, 1);
adjustNumericProperty(entity, STRING_FOLLOWER_COUNT, 1);
graph.commit();
return followed;
}
@Override
public DateTime unfollowEntity(String tenantId, String userId,
String entityId)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == userId)
{
throw new IllegalArgumentException("userId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
Vertex user = getOrCreateEntityVertex(tenantId, userId);
Vertex entity = getOrCreateEntityVertex(tenantId, entityId);
DateTime followed = null;
// remove the follow relationship
for (Edge edge: entity.getEdges(Direction.IN, STRING_FOLLOWS))
{
if (edge.getVertex(Direction.OUT).getId().equals(user.getId()))
{
followed =
DateTime.parse((String)edge.getProperty(STRING_CREATED));
edge.remove();
}
}
if (null != followed)
{
// remove the entity from the user feed by removing the overlay
Vertex currentOverlay = getOverlayForEntity(entity, userId);
Vertex previousOverlay = getPreviousOverlay(currentOverlay);
Vertex nextOverlay = getNextOverlay(currentOverlay);
currentOverlay.remove();
// replace the missing edge for the feed if necessary
if (null != nextOverlay)
{
Edge newEdge = previousOverlay.addEdge(STRING_FEED, nextOverlay);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
}
adjustNumericProperty(user, STRING_FEED_COUNT,
-(int)entity.getProperty(STRING_STREAM_COUNT));
adjustNumericProperty(user, STRING_FOLLOWING_COUNT, -1);
adjustNumericProperty(entity, STRING_FOLLOWER_COUNT, -1);
}
graph.commit();
return followed;
}
@Override
public DateTime getDateTimeUserFollowedEntity(String tenantId,
String userId, String entityId)
{
Vertex user = getOrCreateEntityVertex(tenantId, userId);
Vertex entity = getOrCreateEntityVertex(tenantId, entityId);
for (Edge edge : user.getEdges(Direction.OUT, STRING_FOLLOWS))
{
if (edge.getVertex(Direction.IN).getId().equals(entity.getId()))
{
DateTime followDate =
DateTime.parse((String)edge.getProperty(STRING_CREATED));
graph.commit();
return followDate;
}
}
graph.commit();
return null;
}
@Override
public ActivityStreamsCollection getFollowing(String tenantId,
String userId, int startIndex, int entitiesToReturn)
{
Vertex user = getOrCreateEntityVertex(tenantId, userId);
ActivityStreamsCollection following = new ActivityStreamsCollection();
int currentPosition = 0;
// find all the followed entities and add them to the following list
for (Edge edge : user.getEdges(Direction.OUT, STRING_FOLLOWS))
{
if (currentPosition >= startIndex)
{
ActivityStreamsObject entity = new ActivityStreamsObject();
// remove the tenant id from the entity id before adding
entity.setId((String)edge.getVertex(Direction.IN)
.getProperty(STRING_ENTITY_ID));
following.add(entity);
if (following.size() >= entitiesToReturn)
break;
}
currentPosition++;
}
following.setTotalItems((int)user.getProperty(STRING_FOLLOWING_COUNT));
graph.commit();
return following;
}
@Override
public ActivityStreamsCollection getFollowers(String tenantId,
String entityId, int startIndex, int followersToReturn)
{
Vertex entity = getOrCreateEntityVertex(tenantId, entityId);
ActivityStreamsCollection followers = new ActivityStreamsCollection();
int currentPosition = 0;
// find all the following users and add them to the followers list
for (Edge edge : entity.getEdges(Direction.IN, STRING_FOLLOWS))
{
if (currentPosition >= startIndex)
{
ActivityStreamsObject user = new ActivityStreamsObject();
// remove the tenant id from the user id before adding
user.setId((String)edge.getVertex(Direction.OUT)
.getProperty(STRING_ENTITY_ID));
followers.add(user);
if (followers.size() >= followersToReturn)
break;
}
currentPosition++;
}
followers.setTotalItems((int)entity.getProperty(STRING_FOLLOWER_COUNT));
graph.commit();
return followers;
}
@Override
public void likeActivity(String tenantId, String userId, String entityId,
String activityId)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == userId)
{
throw new IllegalArgumentException("userId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
if (null == activityId)
{
throw new IllegalArgumentException("activityId must not be null");
}
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null == activityVertex)
return;
// already done if there is an incoming like edge matching the user ID
if (activityVertex.query().direction(Direction.IN).labels(STRING_LIKES)
.has(STRING_ENTITY_ID, userId).count() > 0)
return;
Vertex userVertex =
getOrCreateEntityVertex(tenantId, userId);
Edge likeEdge = userVertex.addEdge(STRING_LIKES, activityVertex);
likeEdge.setProperty(STRING_TENANT_ID, tenantId);
likeEdge.setProperty(STRING_ENTITY_ID, userId);
likeEdge.setProperty(STRING_CREATED,
DateTime.now(DateTimeZone.UTC).toString());
adjustNumericProperty(activityVertex, STRING_LIKE_COUNT, 1);
graph.commit();
}
@Override
public void unlikeActivity(String tenantId, String userId, String entityId,
String activityId)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == userId)
{
throw new IllegalArgumentException("userId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
if (null == activityId)
{
throw new IllegalArgumentException("activityId must not be null");
}
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null == activityVertex)
return;
Edge toRemove = null;
for (Edge likeEdge : activityVertex.getEdges(
Direction.IN, STRING_LIKES))
{
if (userId.equals(likeEdge.getProperty(STRING_ENTITY_ID)))
{
toRemove = likeEdge;
break;
}
}
if (null != toRemove)
{
toRemove.remove();
toRemove = null;
adjustNumericProperty(activityVertex, STRING_LIKE_COUNT, -1);
}
graph.commit();
}
@Override
public DateTime userLikesActivity(String tenantId, String userId,
String entityId, String activityId)
{
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null == activityVertex)
return null;
for (Edge likeEdge : activityVertex.getEdges(
Direction.IN, STRING_LIKES))
{
if (userId.equals(likeEdge.getProperty(STRING_ENTITY_ID)))
{
graph.commit();
return DateTime.parse(
(String)likeEdge.getProperty(STRING_CREATED));
}
}
return null;
}
@Override
public ActivityStreamsCollection getLikes(String tenantId,
String entityId, String activityId, int startIndex,
int likesToReturn)
{
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null == activityVertex)
return null;
ActivityStreamsCollection likes = new ActivityStreamsCollection();
int currentPosition = 0;
for(Edge likeEdge : activityVertex.getEdges(
Direction.IN, STRING_LIKES))
{
if (likes.size() >= likesToReturn)
break;
if (currentPosition >= startIndex)
{
ActivityStreamsObject actor = new ActivityStreamsObject();
actor.setId((String)likeEdge.getProperty(STRING_ENTITY_ID));
ActivityStreamsObject activity = new ActivityStreamsObject();
activity.setId(activityId);
Activity like = new Activity();
like.setActor(actor);
like.setObject(activity);
like.setVerb(STRING_LIKE);
like.setPublished(DateTime.parse(
(String)likeEdge.getProperty(STRING_CREATED)));
likes.add(like);
}
currentPosition++;
}
likes.setTotalItems((int)activityVertex.getProperty(STRING_LIKE_COUNT));
return likes;
}
/**
* Inserts an entity into the feed for a user.
*
* @param user The user whose feed will be updated.
* @param newEntity the entity to add to the user's feed.
* @param tenantId the tenant for the feed.
* @return true if the entity is first in the feed, otherwise false.
*/
private boolean insertFeedEntity(final Vertex user, final Vertex newEntity,
final String tenantId)
{
if (null == user)
{
throw new IllegalArgumentException("user must not be null");
}
if (null == newEntity)
{
throw new IllegalArgumentException("newEntity must not be null");
}
String entityId = user.getProperty(STRING_ENTITY_ID);
String now = DateTime.now(DateTimeZone.UTC).toString();
// create the overlay and attach it to the new entity
Vertex newOverlay = graph.addVertex(null);
newOverlay.setProperty(STRING_TYPE, STRING_OVERLAY);
newOverlay.setProperty(STRING_TENANT_ID, tenantId);
newOverlay.setProperty(STRING_ENTITY_ID, entityId);
newOverlay.setProperty(STRING_CREATED, now);
Edge newEdge = newOverlay.addEdge(STRING_FEED_ENTITY, newEntity);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED, now);
// start with the user and the first feed overlay
Edge currentFeedEdge = getFeedEdge(user);
Vertex currentOverlay = getNextOverlay(user);
Vertex previousOverlay = user;
int position = 0;
// we order overlays based on the date of their entity's first activity
// advance along the feed until we find where the new entity belongs
while (currentOverlay != null && firstActivityDateComparator
.compare(newEntity, getFeedEntity(currentOverlay)) > 0)
{
previousOverlay = currentOverlay;
currentFeedEdge = getFeedEdge(currentOverlay);
currentOverlay = getNextOverlay(currentOverlay);
position++;
}
// add an edge from the previous overlay to the new one
newEdge = previousOverlay.addEdge(STRING_FEED, newOverlay);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED, now);
// if there are following overlays, add an edge from the new one to the
// following one
if (null != currentFeedEdge)
{
newEdge = newOverlay.addEdge(STRING_FEED, currentOverlay);
newEdge.setProperty(STRING_TENANT_ID, tenantId);
newEdge.setProperty(STRING_ENTITY_ID, entityId);
newEdge.setProperty(STRING_CREATED, now);
currentFeedEdge.remove();
}
// if we didn't advance, the entity is the first in the feed
return position == 0;
}
@Override
public ActivityStreamsCollection getFeed(String tenantId, String userId,
int startIndex, int activitiesToReturn)
{
// this method represents the core of the Graphity algorithm
// http://www.rene-pickhardt.de/graphity-an-efficient-graph-model-for-retrieving-the-top-k-news-feeds-for-users-in-social-networks/
// a priority queue is used to order the activities that are "next" for
// each entity we've already reached in the feed
PriorityQueue<Vertex> queue =
new PriorityQueue<Vertex>(11, activityDateComparator);
ArrayList<Vertex> activities = new ArrayList<Vertex>();
// since we need to advance from the beginning of the feed, this lets
// us keep track of where we are
int feedPosition = 0;
// this is used to track the newest activity in the last entity reached
Vertex topOfEntity = null;
// this is used to track the newest activity within all of the streams
// for entities that have already been reached
Vertex topOfQueue = null;
Vertex user = getOrCreateEntityVertex(tenantId, userId);
Vertex overlay = getNextOverlay(user);
if (null != overlay)
{
topOfEntity = getNextActivity(getFeedEntity(overlay));
if (null != topOfEntity)
{
queue.add(topOfEntity);
topOfQueue = topOfEntity;
}
overlay = getNextOverlay(overlay);
topOfEntity = getNextActivity(getFeedEntity(overlay));
}
// while we have not yet hit our activities to return,
// and there are still activities in the queue OR
// there are more overlays
while (activities.size() < activitiesToReturn
&& (queue.size() > 0 || overlay != null))
{
// compare top of next entity to top of queue
int result = activityDateComparator.compare(
topOfEntity, topOfQueue);
// if top of next entity is newer, take the top element,
// push the next element to the queue, and move to
// the next entity
if (result < 0)
{
if (feedPosition >= startIndex)
activities.add(topOfEntity);
Vertex nextActivity = getNextActivity(topOfEntity);
if (null != nextActivity)
queue.add(nextActivity);
overlay = getNextOverlay(overlay);
topOfEntity = getNextActivity(getFeedEntity(overlay));
topOfQueue = queue.peek();
}
else
{
// if there's no top of entity and the queue is empty,
// we need to move to the next entity
if (queue.isEmpty())
{
overlay = getNextOverlay(overlay);
topOfEntity = getNextActivity(getFeedEntity(overlay));
}
// if top of queue is newer, take the top element, and
// push the next element to the queue
else
{
Vertex removedFromQueue = queue.remove();
Vertex nextActivity = getNextActivity(removedFromQueue);
if (null != nextActivity)
queue.add(nextActivity);
if (feedPosition >= startIndex)
activities.add(removedFromQueue);
topOfQueue = queue.peek();
}
}
feedPosition++;
}
graph.commit();
return createCollection(activities,
(int)user.getProperty(STRING_FEED_COUNT));
}
/**
* Retrieves the overlay after the given node by following the outgoing
* feed edge. The node can be an entity (user) or an overlay.
*
* @param node The entity or overlay for which to find the next overlay.
* @return The next overlay after the given node, or null if one does not
* exist.
*/
private Vertex getNextOverlay(Vertex node)
{
Edge feedEdge = getFeedEdge(node);
return null == feedEdge ? null : feedEdge.getVertex(Direction.IN);
}
private Vertex getPreviousOverlay(Vertex node)
{
if (null == node)
return null;
Iterator<Vertex> vertices =
node.getVertices(Direction.IN, STRING_FEED).iterator();
Vertex overlay = vertices.hasNext() ? vertices.next() : null;
if (null != overlay)
{
if (vertices.hasNext())
{
logger.error("Multiple previous overlays for node with id: {}",
node.getId());
}
}
return overlay;
}
@Override
public void addComment(String tenantId, String entityId, String activityId,
String userId, ActivityStreamsObject comment)
{
if (null == tenantId)
{
throw new IllegalArgumentException("tenantId must not be null");
}
if (null == entityId)
{
throw new IllegalArgumentException("entityId must not be null");
}
if (null == activityId)
{
throw new IllegalArgumentException("activityId must not be null");
}
if (null == comment)
{
throw new IllegalArgumentException("comment must not be null");
}
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null == activityVertex)
{
graph.commit();
return;
}
Vertex commentVertex =
serializeComment(comment, tenantId, entityId, activityId);
insertComment(activityVertex, commentVertex, userId);
adjustNumericProperty(activityVertex, STRING_COMMENT_COUNT, 1);
graph.commit();
}
/**
* Retrieves the comment after the given node by following the outgoing
* comments edge. The node can be an activity or a comment.
*
* @param node The activity or comment for which to find the next comment.
* @return The next comment after the given node, or null if one does not
* exist.
*/
private Vertex getNextComment(Vertex node)
{
Edge commentEdge = getCommentEdge(node);
return null == commentEdge ? null : commentEdge.getVertex(Direction.IN);
}
private Vertex getPreviousComment(Vertex node)
{
if (null == node)
return null;
Iterator<Vertex> vertices =
node.getVertices(Direction.IN, STRING_COMMENTS).iterator();
Vertex comment = vertices.hasNext() ? vertices.next() : null;
if (null != comment)
{
if (vertices.hasNext())
{
logger.error(
"Multiple previous comments for node with id: {}",
node.getId());
}
}
return comment;
}
@Override
public ActivityStreamsCollection getComments(String tenantId,
String entityId, String activityId, int startIndex,
int commentsToReturn)
{
ActivityStreamsCollection comments = null;
Vertex activityVertex =
getActivityVertex(tenantId, entityId, activityId);
if (null != activityVertex)
{
comments = createCollection(getCommentVertices(
activityVertex, startIndex, commentsToReturn),
(int)activityVertex.getProperty(STRING_COMMENT_COUNT));
}
graph.commit();
return comments;
}
/**
* Retrieves the collection of comment vertices for a given activity vertex.
*
* @param activityVertex The vertex for which to retrieve comments.
* @param startIndex The zero-based index of the first comment to retrieve.
* @param commentsToReturn The maximum number of comments to retrieve.
* @return A collection of comment vertices.
*/
private Collection<Vertex> getCommentVertices(Vertex activityVertex,
int startIndex, int commentsToReturn)
{
// since we need to advance from the beginning of the comments,
// this lets us keep track of where we are
int commentPosition = 0;
// once we reach the number of comments to return, we can stop
int foundCommentCount = 0;
Vertex currentComment = getNextComment(activityVertex);
Collection<Vertex> commentVertices = new ArrayList<Vertex>();
// advance along the comments, collecting vertices after we get to the
// start index, and stopping when we have enough to return or run out
// of comments
while (null != currentComment &&
foundCommentCount < commentsToReturn)
{
if (commentPosition >= startIndex)
{
commentVertices.add(currentComment);
foundCommentCount++;
}
currentComment = getNextComment(currentComment);
commentPosition++;
}
return commentVertices;
}
@Override
public ActivityStreamsObject getEntity(String tenantId, String entityId)
{
Vertex entityVertex = getOrCreateEntityVertex(tenantId, entityId);
ActivityStreamsObject entity = new ActivityStreamsObject();
entity.setId(entityId);
entity.setPublished(DateTime.parse(
(String)entityVertex.getProperty(STRING_CREATED)));
entity.setCollabinateValue(STRING_FOLLOWER_COUNT, Integer.toString(
entityVertex.getProperty(STRING_FOLLOWER_COUNT)));
entity.setCollabinateValue(STRING_FOLLOWING_COUNT, Integer.toString(
entityVertex.getProperty(STRING_FOLLOWING_COUNT)));
entity.setCollabinateValue(STRING_STREAM_COUNT, Integer.toString(
entityVertex.getProperty(STRING_STREAM_COUNT)));
entity.setCollabinateValue(STRING_FEED_COUNT, Integer.toString(
entityVertex.getProperty(STRING_FEED_COUNT)));
return entity;
}
@Override
public void deleteEntity(String tenantId, String entityId)
{
if (null == tenantId)
throw new IllegalArgumentException("tenantId must not be null");
if (null == entityId)
throw new IllegalArgumentException("entityId must not be null");
PartitionGraph<KeyIndexableGraph> tenantGraph =
new PartitionGraph<KeyIndexableGraph>(
graph, STRING_TENANT_ID, tenantId);
for (Vertex entityVertex :
tenantGraph.getVertices(STRING_ENTITY_ID, entityId))
{
entityVertex.remove();
}
}
/**
* A comparator for activity vertices that orders by the sort time.
*
* @author mafuba
*
*/
private class ActivityDateComparator implements Comparator<Vertex>
{
@Override
public int compare(Vertex v1, Vertex v2)
{
long t1 = Long.MIN_VALUE;
if (null != v1)
t1 = DateTime.parse((String)v1.getProperty(STRING_SORTTIME))
.getMillis();
long t2 = Long.MIN_VALUE;
if (null != v2)
t2 = DateTime.parse((String)v2.getProperty(STRING_SORTTIME))
.getMillis();
return new Long(t2).compareTo(t1);
}
}
@Override
public void deleteComment(String tenantId, String entityId,
String activityId, String commentId)
{
if (null == tenantId)
throw new IllegalArgumentException("tenantId must not be null");
if (null == entityId)
throw new IllegalArgumentException("entityId must not be null");
if (null == activityId)
throw new IllegalArgumentException("activityId must not be null");
if (null == commentId)
throw new IllegalArgumentException("commentId must not be null");
Vertex commentVertex = getCommentVertex(tenantId, entityId,
activityId, commentId);
if (null != commentVertex)
{
removeComment(commentVertex);
adjustNumericProperty(getActivityVertex(
tenantId, entityId, activityId), STRING_COMMENT_COUNT, -1);
}
graph.commit();
}
/**
* A comparator for entity vertices that orders by the time of the first
* activity for the entity.
*
* @author mafuba
*
*/
private class EntityFirstActivityDateComparator
implements Comparator<Vertex>
{
@Override
public int compare(Vertex v1, Vertex v2)
{
Vertex activity = null;
long t1 = Long.MIN_VALUE;
if (null != v1)
activity = getNextActivity(v1);
if (null != activity)
t1 = DateTime.parse((String)activity
.getProperty(STRING_SORTTIME)).getMillis();
activity = null;
long t2 = Long.MIN_VALUE;
if (null != v2)
activity = getNextActivity(v2);
if (null != activity)
t2 = DateTime.parse((String)activity
.getProperty(STRING_SORTTIME)).getMillis();
return new Long(t2).compareTo(t1);
}
}
private static final String STRING_ID_SEPARATOR = ".";
private static final String STRING_TENANT_ID = "TenantID";
private static final String STRING_ENTITY_ID = "EntityID";
private static final String STRING_ENTITY = "Entity";
private static final String STRING_ACTIVITY_ID = "ActivityID";
private static final String STRING_ACTIVITY = "Activity";
private static final String STRING_COMMENT_ID = "CommentID";
private static final String STRING_COMMENT = "Comment";
private static final String STRING_COMMENT_COUNT = "CommentCount";
private static final String STRING_COMMENTS = "Comments";
private static final String STRING_COMMENTED = "Commented";
private static final String STRING_SORTTIME = "SortTime";
private static final String STRING_CONTENT = "Content";
private static final String STRING_FOLLOWS = "Follows";
private static final String STRING_FOLLOWING_COUNT = "FollowingCount";
private static final String STRING_FOLLOWER_COUNT = "FollowerCount";
private static final String STRING_STREAM = "Stream";
private static final String STRING_STREAM_COUNT = "StreamCount";
private static final String STRING_FEED = "Feed";
private static final String STRING_FEED_ENTITY = "FeedEntity";
private static final String STRING_FEED_COUNT = "FeedCount";
private static final String STRING_OVERLAY = "Overlay";
private static final String STRING_TYPE = "Type";
private static final String STRING_CREATED = "Created";
private static final String STRING_LIKES = "Likes";
private static final String STRING_LIKE = "like";
private static final String STRING_LIKE_COUNT = "LikeCount";
}