James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2013 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.tickets;
17
18 import java.net.URI;
19 import java.util.ArrayList;
20 import java.util.Collections;
21 import java.util.List;
22 import java.util.Set;
4d81c9 23 import java.util.TreeSet;
5e3521 24
JM 25 import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
26
27 import redis.clients.jedis.Client;
28 import redis.clients.jedis.Jedis;
29 import redis.clients.jedis.JedisPool;
30 import redis.clients.jedis.Protocol;
31 import redis.clients.jedis.Transaction;
32 import redis.clients.jedis.exceptions.JedisException;
33
34 import com.gitblit.Keys;
35 import com.gitblit.manager.INotificationManager;
ba5670 36 import com.gitblit.manager.IPluginManager;
5e3521 37 import com.gitblit.manager.IRepositoryManager;
JM 38 import com.gitblit.manager.IRuntimeManager;
39 import com.gitblit.manager.IUserManager;
40 import com.gitblit.models.RepositoryModel;
41 import com.gitblit.models.TicketModel;
42 import com.gitblit.models.TicketModel.Attachment;
43 import com.gitblit.models.TicketModel.Change;
44 import com.gitblit.utils.ArrayUtils;
45 import com.gitblit.utils.StringUtils;
c42032 46 import com.google.inject.Inject;
JM 47 import com.google.inject.Singleton;
5e3521 48
JM 49 /**
50  * Implementation of a ticket service based on a Redis key-value store.  All
51  * tickets are persisted in the Redis store so it must be configured for
52  * durability otherwise tickets are lost on a flush or restart.  Tickets are
53  * indexed with Lucene and all queries are executed against the Lucene index.
54  *
55  * @author James Moger
56  *
57  */
aa1361 58 @Singleton
5e3521 59 public class RedisTicketService extends ITicketService {
JM 60
61     private final JedisPool pool;
62
63     private enum KeyType {
64         journal, ticket, counter
65     }
66
aa1361 67     @Inject
5e3521 68     public RedisTicketService(
JM 69             IRuntimeManager runtimeManager,
ba5670 70             IPluginManager pluginManager,
5e3521 71             INotificationManager notificationManager,
JM 72             IUserManager userManager,
73             IRepositoryManager repositoryManager) {
74
75         super(runtimeManager,
ba5670 76                 pluginManager,
5e3521 77                 notificationManager,
JM 78                 userManager,
79                 repositoryManager);
80
81         String redisUrl = settings.getString(Keys.tickets.redis.url, "");
82         this.pool = createPool(redisUrl);
83     }
84
85     @Override
86     public RedisTicketService start() {
c42032 87         log.info("{} started", getClass().getSimpleName());
JM 88         if (!isReady()) {
89             log.warn("{} is not ready!", getClass().getSimpleName());
90         }
5e3521 91         return this;
JM 92     }
93
94     @Override
95     protected void resetCachesImpl() {
96     }
97
98     @Override
99     protected void resetCachesImpl(RepositoryModel repository) {
100     }
101
102     @Override
103     protected void close() {
104         pool.destroy();
105     }
106
107     @Override
108     public boolean isReady() {
109         return pool != null;
110     }
111
112     /**
113      * Constructs a key for use with a key-value data store.
114      *
115      * @param key
116      * @param repository
117      * @param id
118      * @return a key
119      */
120     private String key(RepositoryModel repository, KeyType key, String id) {
121         StringBuilder sb = new StringBuilder();
122         sb.append(repository.name).append(':');
123         sb.append(key.name());
124         if (!StringUtils.isEmpty(id)) {
125             sb.append(':');
126             sb.append(id);
127         }
128         return sb.toString();
129     }
130
131     /**
132      * Constructs a key for use with a key-value data store.
133      *
134      * @param key
135      * @param repository
136      * @param id
137      * @return a key
138      */
139     private String key(RepositoryModel repository, KeyType key, long id) {
140         return key(repository, key, "" + id);
141     }
142
143     private boolean isNull(String value) {
144         return value == null || "nil".equals(value);
145     }
146
147     private String getUrl() {
148         Jedis jedis = pool.getResource();
149         try {
150             if (jedis != null) {
151                 Client client = jedis.getClient();
152                 return client.getHost() + ":" + client.getPort() + "/" + client.getDB();
153             }
154         } catch (JedisException e) {
155             pool.returnBrokenResource(jedis);
156             jedis = null;
157         } finally {
158             if (jedis != null) {
159                 pool.returnResource(jedis);
160             }
161         }
162         return null;
163     }
164
165     /**
166      * Ensures that we have a ticket for this ticket id.
167      *
168      * @param repository
169      * @param ticketId
170      * @return true if the ticket exists
171      */
172     @Override
173     public boolean hasTicket(RepositoryModel repository, long ticketId) {
174         if (ticketId <= 0L) {
175             return false;
176         }
177         Jedis jedis = pool.getResource();
178         if (jedis == null) {
179             return false;
180         }
181         try {
182             Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId));
4d537b 183             return exists != null && exists;
5e3521 184         } catch (JedisException e) {
JM 185             log.error("failed to check hasTicket from Redis @ " + getUrl(), e);
186             pool.returnBrokenResource(jedis);
187             jedis = null;
188         } finally {
189             if (jedis != null) {
190                 pool.returnResource(jedis);
191             }
192         }
193         return false;
194     }
195
4d81c9 196     @Override
JM 197     public Set<Long> getIds(RepositoryModel repository) {
198         Set<Long> ids = new TreeSet<Long>();
199         Jedis jedis = pool.getResource();
200         try {// account for migrated tickets
201             Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
202             for (String tkey : keys) {
203                 // {repo}:journal:{id}
204                 String id = tkey.split(":")[2];
205                 long ticketId = Long.parseLong(id);
206                 ids.add(ticketId);
207             }
208         } catch (JedisException e) {
209             log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
210             pool.returnBrokenResource(jedis);
211             jedis = null;
212         } finally {
213             if (jedis != null) {
214                 pool.returnResource(jedis);
215             }
216         }
217         return ids;
218     }
219
5e3521 220     /**
JM 221      * Assigns a new ticket id.
222      *
223      * @param repository
224      * @return a new long ticket id
225      */
226     @Override
227     public synchronized long assignNewId(RepositoryModel repository) {
228         Jedis jedis = pool.getResource();
229         try {
230             String key = key(repository, KeyType.counter, null);
231             String val = jedis.get(key);
232             if (isNull(val)) {
4d81c9 233                 long lastId = 0;
JM 234                 Set<Long> ids = getIds(repository);
235                 for (long id : ids) {
236                     if (id > lastId) {
237                         lastId = id;
238                     }
239                 }
240                 jedis.set(key, "" + lastId);
5e3521 241             }
JM 242             long ticketNumber = jedis.incr(key);
243             return ticketNumber;
244         } catch (JedisException e) {
245             log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
246             pool.returnBrokenResource(jedis);
247             jedis = null;
248         } finally {
249             if (jedis != null) {
250                 pool.returnResource(jedis);
251             }
252         }
253         return 0L;
254     }
255
256     /**
257      * Returns all the tickets in the repository. Querying tickets from the
258      * repository requires deserializing all tickets. This is an  expensive
259      * process and not recommended. Tickets should be indexed by Lucene and
260      * queries should be executed against that index.
261      *
262      * @param repository
263      * @param filter
264      *            optional filter to only return matching results
265      * @return a list of tickets
266      */
267     @Override
268     public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
269         Jedis jedis = pool.getResource();
270         List<TicketModel> list = new ArrayList<TicketModel>();
271         if (jedis == null) {
272             return list;
273         }
274         try {
275             // Deserialize each journal, build the ticket, and optionally filter
276             Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
277             for (String key : keys) {
278                 // {repo}:journal:{id}
279                 String id = key.split(":")[2];
280                 long ticketId = Long.parseLong(id);
281                 List<Change> changes = getJournal(jedis, repository, ticketId);
282                 if (ArrayUtils.isEmpty(changes)) {
283                     log.warn("Empty journal for {}:{}", repository, ticketId);
284                     continue;
285                 }
286                 TicketModel ticket = TicketModel.buildTicket(changes);
287                 ticket.project = repository.projectPath;
288                 ticket.repository = repository.name;
289                 ticket.number = ticketId;
290
291                 // add the ticket, conditionally, to the list
292                 if (filter == null) {
293                     list.add(ticket);
294                 } else {
295                     if (filter.accept(ticket)) {
296                         list.add(ticket);
297                     }
298                 }
299             }
300
301             // sort the tickets by creation
302             Collections.sort(list);
303         } catch (JedisException e) {
304             log.error("failed to retrieve tickets from Redis @ " + getUrl(), e);
305             pool.returnBrokenResource(jedis);
306             jedis = null;
307         } finally {
308             if (jedis != null) {
309                 pool.returnResource(jedis);
310             }
311         }
312         return list;
313     }
314
315     /**
4d81c9 316      * Retrieves the ticket from the repository.
5e3521 317      *
JM 318      * @param repository
319      * @param ticketId
320      * @return a ticket, if it exists, otherwise null
321      */
322     @Override
323     protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
324         Jedis jedis = pool.getResource();
325         if (jedis == null) {
326             return null;
327         }
328
329         try {
330             List<Change> changes = getJournal(jedis, repository, ticketId);
331             if (ArrayUtils.isEmpty(changes)) {
332                 log.warn("Empty journal for {}:{}", repository, ticketId);
333                 return null;
334             }
335             TicketModel ticket = TicketModel.buildTicket(changes);
336             ticket.project = repository.projectPath;
337             ticket.repository = repository.name;
338             ticket.number = ticketId;
339             log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl());
340             return ticket;
341         } catch (JedisException e) {
342             log.error("failed to retrieve ticket from Redis @ " + getUrl(), e);
343             pool.returnBrokenResource(jedis);
344             jedis = null;
345         } finally {
346             if (jedis != null) {
347                 pool.returnResource(jedis);
348             }
349         }
350         return null;
351     }
352
353     /**
4d81c9 354      * Retrieves the journal for the ticket.
JM 355      *
356      * @param repository
357      * @param ticketId
358      * @return a journal, if it exists, otherwise null
359      */
360     @Override
361     protected List<Change> getJournalImpl(RepositoryModel repository, long ticketId) {
362         Jedis jedis = pool.getResource();
363         if (jedis == null) {
364             return null;
365         }
366
367         try {
368             List<Change> changes = getJournal(jedis, repository, ticketId);
369             if (ArrayUtils.isEmpty(changes)) {
370                 log.warn("Empty journal for {}:{}", repository, ticketId);
371                 return null;
372             }
373             return changes;
374         } catch (JedisException e) {
375             log.error("failed to retrieve journal from Redis @ " + getUrl(), e);
376             pool.returnBrokenResource(jedis);
377             jedis = null;
378         } finally {
379             if (jedis != null) {
380                 pool.returnResource(jedis);
381             }
382         }
383         return null;
384     }
385
386     /**
5e3521 387      * Returns the journal for the specified ticket.
JM 388      *
389      * @param repository
390      * @param ticketId
391      * @return a list of changes
392      */
393     private List<Change> getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException {
394         if (ticketId <= 0L) {
395             return new ArrayList<Change>();
396         }
397         List<String> entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1);
398         if (entries.size() > 0) {
399             // build a json array from the individual entries
400             StringBuilder sb = new StringBuilder();
401             sb.append("[");
402             for (String entry : entries) {
403                 sb.append(entry).append(',');
404             }
405             sb.setLength(sb.length() - 1);
406             sb.append(']');
407             String journal = sb.toString();
408
409             return TicketSerializer.deserializeJournal(journal);
410         }
411         return new ArrayList<Change>();
412     }
413
414     @Override
415     public boolean supportsAttachments() {
416         return false;
417     }
418
419     /**
420      * Retrieves the specified attachment from a ticket.
421      *
422      * @param repository
423      * @param ticketId
424      * @param filename
425      * @return an attachment, if found, null otherwise
426      */
427     @Override
428     public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
429         return null;
430     }
431
432     /**
433      * Deletes a ticket.
434      *
435      * @param ticket
436      * @return true if successful
437      */
438     @Override
439     protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
440         boolean success = false;
441         if (ticket == null) {
442             throw new RuntimeException("must specify a ticket!");
443         }
444
445         Jedis jedis = pool.getResource();
446         if (jedis == null) {
447             return false;
448         }
449
450         try {
451             // atomically remove ticket
452             Transaction t = jedis.multi();
453             t.del(key(repository, KeyType.ticket, ticket.number));
454             t.del(key(repository, KeyType.journal, ticket.number));
455             t.exec();
456
457             success = true;
458             log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl());
459         } catch (JedisException e) {
460             log.error("failed to delete ticket from Redis @ " + getUrl(), e);
461             pool.returnBrokenResource(jedis);
462             jedis = null;
463         } finally {
464             if (jedis != null) {
465                 pool.returnResource(jedis);
466             }
467         }
468
469         return success;
470     }
471
472     /**
473      * Commit a ticket change to the repository.
474      *
475      * @param repository
476      * @param ticketId
477      * @param change
478      * @return true, if the change was committed
479      */
480     @Override
481     protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
482         Jedis jedis = pool.getResource();
483         if (jedis == null) {
484             return false;
485         }
486         try {
487             List<Change> changes = getJournal(jedis, repository, ticketId);
488             changes.add(change);
489             // build a new effective ticket from the changes
490             TicketModel ticket = TicketModel.buildTicket(changes);
491
492             String object = TicketSerializer.serialize(ticket);
493             String journal = TicketSerializer.serialize(change);
494
495             // atomically store ticket
496             Transaction t = jedis.multi();
497             t.set(key(repository, KeyType.ticket, ticketId), object);
498             t.rpush(key(repository, KeyType.journal, ticketId), journal);
499             t.exec();
500
501             log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl());
502             return true;
503         } catch (JedisException e) {
504             log.error("failed to update ticket cache in Redis @ " + getUrl(), e);
505             pool.returnBrokenResource(jedis);
506             jedis = null;
507         } finally {
508             if (jedis != null) {
509                 pool.returnResource(jedis);
510             }
511         }
512         return false;
513     }
514
515     /**
516      *  Deletes all Tickets for the rpeository from the Redis key-value store.
517      *
518      */
519     @Override
520     protected boolean deleteAllImpl(RepositoryModel repository) {
521         Jedis jedis = pool.getResource();
522         if (jedis == null) {
523             return false;
524         }
525
526         boolean success = false;
527         try {
528             Set<String> keys = jedis.keys(repository.name + ":*");
529             if (keys.size() > 0) {
530                 Transaction t = jedis.multi();
531                 t.del(keys.toArray(new String[keys.size()]));
532                 t.exec();
533             }
534             success = true;
535         } catch (JedisException e) {
536             log.error("failed to delete all tickets in Redis @ " + getUrl(), e);
537             pool.returnBrokenResource(jedis);
538             jedis = null;
539         } finally {
540             if (jedis != null) {
541                 pool.returnResource(jedis);
542             }
543         }
544         return success;
545     }
546
547     @Override
548     protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
549         Jedis jedis = pool.getResource();
550         if (jedis == null) {
551             return false;
552         }
553
554         boolean success = false;
555         try {
556             Set<String> oldKeys = jedis.keys(oldRepository.name + ":*");
557             Transaction t = jedis.multi();
558             for (String oldKey : oldKeys) {
559                 String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':'));
560                 t.rename(oldKey, newKey);
561             }
562             t.exec();
563             success = true;
564         } catch (JedisException e) {
565             log.error("failed to rename tickets in Redis @ " + getUrl(), e);
566             pool.returnBrokenResource(jedis);
567             jedis = null;
568         } finally {
569             if (jedis != null) {
570                 pool.returnResource(jedis);
571             }
572         }
573         return success;
574     }
575
576     private JedisPool createPool(String url) {
577         JedisPool pool = null;
578         if (!StringUtils.isEmpty(url)) {
579             try {
580                 URI uri = URI.create(url);
581                 if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) {
582                     int database = Protocol.DEFAULT_DATABASE;
583                     String password = null;
584                     if (uri.getUserInfo() != null) {
585                         password = uri.getUserInfo().split(":", 2)[1];
586                     }
587                     if (uri.getPath().indexOf('/') > -1) {
588                         database = Integer.parseInt(uri.getPath().split("/", 2)[1]);
589                     }
590                     pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database);
591                 } else {
592                     pool = new JedisPool(url);
593                 }
594             } catch (JedisException e) {
595                 log.error("failed to create a Redis pool!", e);
596             }
597         }
598         return pool;
599     }
600
601     @Override
602     public String toString() {
603         String url = getUrl();
604         return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")";
605     }
606 }