summaryrefslogtreecommitdiffstats
path: root/src/main/java/com/gitblit/tickets/RedisTicketService.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com/gitblit/tickets/RedisTicketService.java')
-rw-r--r--src/main/java/com/gitblit/tickets/RedisTicketService.java534
1 files changed, 534 insertions, 0 deletions
diff --git a/src/main/java/com/gitblit/tickets/RedisTicketService.java b/src/main/java/com/gitblit/tickets/RedisTicketService.java
new file mode 100644
index 00000000..5653f698
--- /dev/null
+++ b/src/main/java/com/gitblit/tickets/RedisTicketService.java
@@ -0,0 +1,534 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * 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.gitblit.tickets;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
+
+import redis.clients.jedis.Client;
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.JedisPool;
+import redis.clients.jedis.Protocol;
+import redis.clients.jedis.Transaction;
+import redis.clients.jedis.exceptions.JedisException;
+
+import com.gitblit.Keys;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Implementation of a ticket service based on a Redis key-value store. All
+ * tickets are persisted in the Redis store so it must be configured for
+ * durability otherwise tickets are lost on a flush or restart. Tickets are
+ * indexed with Lucene and all queries are executed against the Lucene index.
+ *
+ * @author James Moger
+ *
+ */
+public class RedisTicketService extends ITicketService {
+
+ private final JedisPool pool;
+
+ private enum KeyType {
+ journal, ticket, counter
+ }
+
+ @Inject
+ public RedisTicketService(
+ IRuntimeManager runtimeManager,
+ INotificationManager notificationManager,
+ IUserManager userManager,
+ IRepositoryManager repositoryManager) {
+
+ super(runtimeManager,
+ notificationManager,
+ userManager,
+ repositoryManager);
+
+ String redisUrl = settings.getString(Keys.tickets.redis.url, "");
+ this.pool = createPool(redisUrl);
+ }
+
+ @Override
+ public RedisTicketService start() {
+ return this;
+ }
+
+ @Override
+ protected void resetCachesImpl() {
+ }
+
+ @Override
+ protected void resetCachesImpl(RepositoryModel repository) {
+ }
+
+ @Override
+ protected void close() {
+ pool.destroy();
+ }
+
+ @Override
+ public boolean isReady() {
+ return pool != null;
+ }
+
+ /**
+ * Constructs a key for use with a key-value data store.
+ *
+ * @param key
+ * @param repository
+ * @param id
+ * @return a key
+ */
+ private String key(RepositoryModel repository, KeyType key, String id) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(repository.name).append(':');
+ sb.append(key.name());
+ if (!StringUtils.isEmpty(id)) {
+ sb.append(':');
+ sb.append(id);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Constructs a key for use with a key-value data store.
+ *
+ * @param key
+ * @param repository
+ * @param id
+ * @return a key
+ */
+ private String key(RepositoryModel repository, KeyType key, long id) {
+ return key(repository, key, "" + id);
+ }
+
+ private boolean isNull(String value) {
+ return value == null || "nil".equals(value);
+ }
+
+ private String getUrl() {
+ Jedis jedis = pool.getResource();
+ try {
+ if (jedis != null) {
+ Client client = jedis.getClient();
+ return client.getHost() + ":" + client.getPort() + "/" + client.getDB();
+ }
+ } catch (JedisException e) {
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Ensures that we have a ticket for this ticket id.
+ *
+ * @param repository
+ * @param ticketId
+ * @return true if the ticket exists
+ */
+ @Override
+ public boolean hasTicket(RepositoryModel repository, long ticketId) {
+ if (ticketId <= 0L) {
+ return false;
+ }
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return false;
+ }
+ try {
+ Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId));
+ return exists != null && !exists;
+ } catch (JedisException e) {
+ log.error("failed to check hasTicket from Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Assigns a new ticket id.
+ *
+ * @param repository
+ * @return a new long ticket id
+ */
+ @Override
+ public synchronized long assignNewId(RepositoryModel repository) {
+ Jedis jedis = pool.getResource();
+ try {
+ String key = key(repository, KeyType.counter, null);
+ String val = jedis.get(key);
+ if (isNull(val)) {
+ jedis.set(key, "0");
+ }
+ long ticketNumber = jedis.incr(key);
+ return ticketNumber;
+ } catch (JedisException e) {
+ log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return 0L;
+ }
+
+ /**
+ * Returns all the tickets in the repository. Querying tickets from the
+ * repository requires deserializing all tickets. This is an expensive
+ * process and not recommended. Tickets should be indexed by Lucene and
+ * queries should be executed against that index.
+ *
+ * @param repository
+ * @param filter
+ * optional filter to only return matching results
+ * @return a list of tickets
+ */
+ @Override
+ public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
+ Jedis jedis = pool.getResource();
+ List<TicketModel> list = new ArrayList<TicketModel>();
+ if (jedis == null) {
+ return list;
+ }
+ try {
+ // Deserialize each journal, build the ticket, and optionally filter
+ Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
+ for (String key : keys) {
+ // {repo}:journal:{id}
+ String id = key.split(":")[2];
+ long ticketId = Long.parseLong(id);
+ List<Change> changes = getJournal(jedis, repository, ticketId);
+ if (ArrayUtils.isEmpty(changes)) {
+ log.warn("Empty journal for {}:{}", repository, ticketId);
+ continue;
+ }
+ TicketModel ticket = TicketModel.buildTicket(changes);
+ ticket.project = repository.projectPath;
+ ticket.repository = repository.name;
+ ticket.number = ticketId;
+
+ // add the ticket, conditionally, to the list
+ if (filter == null) {
+ list.add(ticket);
+ } else {
+ if (filter.accept(ticket)) {
+ list.add(ticket);
+ }
+ }
+ }
+
+ // sort the tickets by creation
+ Collections.sort(list);
+ } catch (JedisException e) {
+ log.error("failed to retrieve tickets from Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return list;
+ }
+
+ /**
+ * Retrieves the ticket from the repository by first looking-up the changeId
+ * associated with the ticketId.
+ *
+ * @param repository
+ * @param ticketId
+ * @return a ticket, if it exists, otherwise null
+ */
+ @Override
+ protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return null;
+ }
+
+ try {
+ List<Change> changes = getJournal(jedis, repository, ticketId);
+ if (ArrayUtils.isEmpty(changes)) {
+ log.warn("Empty journal for {}:{}", repository, ticketId);
+ return null;
+ }
+ TicketModel ticket = TicketModel.buildTicket(changes);
+ ticket.project = repository.projectPath;
+ ticket.repository = repository.name;
+ ticket.number = ticketId;
+ log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl());
+ return ticket;
+ } catch (JedisException e) {
+ log.error("failed to retrieve ticket from Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the journal for the specified ticket.
+ *
+ * @param repository
+ * @param ticketId
+ * @return a list of changes
+ */
+ private List<Change> getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException {
+ if (ticketId <= 0L) {
+ return new ArrayList<Change>();
+ }
+ List<String> entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1);
+ if (entries.size() > 0) {
+ // build a json array from the individual entries
+ StringBuilder sb = new StringBuilder();
+ sb.append("[");
+ for (String entry : entries) {
+ sb.append(entry).append(',');
+ }
+ sb.setLength(sb.length() - 1);
+ sb.append(']');
+ String journal = sb.toString();
+
+ return TicketSerializer.deserializeJournal(journal);
+ }
+ return new ArrayList<Change>();
+ }
+
+ @Override
+ public boolean supportsAttachments() {
+ return false;
+ }
+
+ /**
+ * Retrieves the specified attachment from a ticket.
+ *
+ * @param repository
+ * @param ticketId
+ * @param filename
+ * @return an attachment, if found, null otherwise
+ */
+ @Override
+ public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
+ return null;
+ }
+
+ /**
+ * Deletes a ticket.
+ *
+ * @param ticket
+ * @return true if successful
+ */
+ @Override
+ protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
+ boolean success = false;
+ if (ticket == null) {
+ throw new RuntimeException("must specify a ticket!");
+ }
+
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return false;
+ }
+
+ try {
+ // atomically remove ticket
+ Transaction t = jedis.multi();
+ t.del(key(repository, KeyType.ticket, ticket.number));
+ t.del(key(repository, KeyType.journal, ticket.number));
+ t.exec();
+
+ success = true;
+ log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl());
+ } catch (JedisException e) {
+ log.error("failed to delete ticket from Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+
+ return success;
+ }
+
+ /**
+ * Commit a ticket change to the repository.
+ *
+ * @param repository
+ * @param ticketId
+ * @param change
+ * @return true, if the change was committed
+ */
+ @Override
+ protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return false;
+ }
+ try {
+ List<Change> changes = getJournal(jedis, repository, ticketId);
+ changes.add(change);
+ // build a new effective ticket from the changes
+ TicketModel ticket = TicketModel.buildTicket(changes);
+
+ String object = TicketSerializer.serialize(ticket);
+ String journal = TicketSerializer.serialize(change);
+
+ // atomically store ticket
+ Transaction t = jedis.multi();
+ t.set(key(repository, KeyType.ticket, ticketId), object);
+ t.rpush(key(repository, KeyType.journal, ticketId), journal);
+ t.exec();
+
+ log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl());
+ return true;
+ } catch (JedisException e) {
+ log.error("failed to update ticket cache in Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Deletes all Tickets for the rpeository from the Redis key-value store.
+ *
+ */
+ @Override
+ protected boolean deleteAllImpl(RepositoryModel repository) {
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return false;
+ }
+
+ boolean success = false;
+ try {
+ Set<String> keys = jedis.keys(repository.name + ":*");
+ if (keys.size() > 0) {
+ Transaction t = jedis.multi();
+ t.del(keys.toArray(new String[keys.size()]));
+ t.exec();
+ }
+ success = true;
+ } catch (JedisException e) {
+ log.error("failed to delete all tickets in Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return success;
+ }
+
+ @Override
+ protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
+ Jedis jedis = pool.getResource();
+ if (jedis == null) {
+ return false;
+ }
+
+ boolean success = false;
+ try {
+ Set<String> oldKeys = jedis.keys(oldRepository.name + ":*");
+ Transaction t = jedis.multi();
+ for (String oldKey : oldKeys) {
+ String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':'));
+ t.rename(oldKey, newKey);
+ }
+ t.exec();
+ success = true;
+ } catch (JedisException e) {
+ log.error("failed to rename tickets in Redis @ " + getUrl(), e);
+ pool.returnBrokenResource(jedis);
+ jedis = null;
+ } finally {
+ if (jedis != null) {
+ pool.returnResource(jedis);
+ }
+ }
+ return success;
+ }
+
+ private JedisPool createPool(String url) {
+ JedisPool pool = null;
+ if (!StringUtils.isEmpty(url)) {
+ try {
+ URI uri = URI.create(url);
+ if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) {
+ int database = Protocol.DEFAULT_DATABASE;
+ String password = null;
+ if (uri.getUserInfo() != null) {
+ password = uri.getUserInfo().split(":", 2)[1];
+ }
+ if (uri.getPath().indexOf('/') > -1) {
+ database = Integer.parseInt(uri.getPath().split("/", 2)[1]);
+ }
+ pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database);
+ } else {
+ pool = new JedisPool(url);
+ }
+ } catch (JedisException e) {
+ log.error("failed to create a Redis pool!", e);
+ }
+ }
+ return pool;
+ }
+
+ @Override
+ public String toString() {
+ String url = getUrl();
+ return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")";
+ }
+}