001/**
002 * Copyright 2005-2016 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.kim.service.impl;
017
018import org.apache.commons.lang.StringUtils;
019import org.apache.log4j.Level;
020import org.apache.log4j.Logger;
021import org.kuali.rice.core.api.config.property.ConfigurationService;
022import org.kuali.rice.kim.api.KimConstants;
023import org.kuali.rice.kim.impl.identity.IdentityArchiveService;
024import org.kuali.rice.kim.api.identity.entity.EntityDefault;
025import org.kuali.rice.kim.api.identity.principal.Principal;
026import org.kuali.rice.kim.impl.identity.EntityDefaultInfoCacheBo;
027import org.kuali.rice.krad.service.BusinessObjectService;
028import org.kuali.rice.krad.service.KRADServiceLocatorInternal;
029import org.kuali.rice.ksb.service.KSBServiceLocator;
030import org.springframework.beans.factory.DisposableBean;
031import org.springframework.beans.factory.InitializingBean;
032import org.springframework.transaction.PlatformTransactionManager;
033import org.springframework.transaction.TransactionStatus;
034import org.springframework.transaction.support.TransactionCallback;
035import org.springframework.transaction.support.TransactionTemplate;
036
037import java.util.ArrayList;
038import java.util.Arrays;
039import java.util.Collection;
040import java.util.Collections;
041import java.util.Comparator;
042import java.util.HashMap;
043import java.util.HashSet;
044import java.util.List;
045import java.util.Map;
046import java.util.Set;
047import java.util.concurrent.Callable;
048import java.util.concurrent.ConcurrentLinkedQueue;
049import java.util.concurrent.TimeUnit;
050import java.util.concurrent.atomic.AtomicBoolean;
051import java.util.concurrent.atomic.AtomicInteger;
052
053/**
054 * This is the default implementation for the IdentityArchiveService.
055 * @see IdentityArchiveService
056 * @author Kuali Rice Team (rice.collab@kuali.org)
057 *
058 */
059public class IdentityArchiveServiceImpl implements IdentityArchiveService, InitializingBean, DisposableBean {
060        private static final Logger LOG = Logger.getLogger( IdentityArchiveServiceImpl.class );
061
062        private BusinessObjectService businessObjectService;
063        private ConfigurationService kualiConfigurationService;
064    private PlatformTransactionManager transactionManager;
065
066        private static final String EXEC_INTERVAL_SECS = "kim.identityArchiveServiceImpl.executionIntervalSeconds";
067        private static final String MAX_WRITE_QUEUE_SIZE = "kim.identityArchiveServiceImpl.maxWriteQueueSize";
068        private static final int EXECUTION_INTERVAL_SECONDS_DEFAULT = 600; // by default, flush the write queue this often
069        private static final int MAX_WRITE_QUEUE_SIZE_DEFAULT = 300; // cache this many KEDI's before forcing write
070
071        private final WriteQueue writeQueue = new WriteQueue();
072        private final EntityArchiveWriter writer = new EntityArchiveWriter();
073
074        // all this ceremony just decorates the writer so it logs a message first, and converts the Callable to Runnable
075        private final Runnable maxQueueSizeExceededWriter =
076                new CallableAdapter(new PreLogCallableWrapper<Boolean>(writer, Level.DEBUG, "max size exceeded, flushing write queue"));
077
078        // ditto
079        private final Runnable scheduledWriter =
080                new CallableAdapter(new PreLogCallableWrapper<Boolean>(writer, Level.DEBUG, "scheduled write out, flushing write queue"));
081
082        // ditto
083        private final Runnable shutdownWriter =
084                new CallableAdapter(new PreLogCallableWrapper<Boolean>(writer, Level.DEBUG, "rice is shutting down, flushing write queue"));
085        
086        private int getExecutionIntervalSeconds() {
087                final String prop = kualiConfigurationService.getPropertyValueAsString(EXEC_INTERVAL_SECS);
088                try {
089                        return Integer.valueOf(prop).intValue();
090                } catch (NumberFormatException e) {
091                        return EXECUTION_INTERVAL_SECONDS_DEFAULT;
092                }
093        }
094        
095        private int getMaxWriteQueueSize() {
096                final String prop = kualiConfigurationService.getPropertyValueAsString(MAX_WRITE_QUEUE_SIZE);
097                try {
098                        return Integer.valueOf(prop).intValue();
099                } catch (NumberFormatException e) {
100                        return MAX_WRITE_QUEUE_SIZE_DEFAULT;
101                }
102        }
103
104        @Override
105        public EntityDefault getEntityDefaultFromArchive( String entityId ) {
106        if (StringUtils.isBlank(entityId)) {
107            throw new IllegalArgumentException("entityId is blank");
108        }
109
110        Map<String,String> criteria = new HashMap<String, String>(1);
111        criteria.put(KimConstants.PrimaryKeyConstants.SUB_ENTITY_ID, entityId);
112        EntityDefaultInfoCacheBo cachedValue = businessObjectService.findByPrimaryKey(EntityDefaultInfoCacheBo.class, criteria);
113        return (cachedValue == null) ? null : cachedValue.convertCacheToEntityDefaultInfo();
114    }
115
116    @Override
117        public EntityDefault getEntityDefaultFromArchiveByPrincipalId(String principalId) {
118        if (StringUtils.isBlank(principalId)) {
119            throw new IllegalArgumentException("principalId is blank");
120        }
121
122        Map<String,String> criteria = new HashMap<String, String>(1);
123        criteria.put("principalId", principalId);
124        EntityDefaultInfoCacheBo cachedValue = businessObjectService.findByPrimaryKey(EntityDefaultInfoCacheBo.class, criteria);
125        return (cachedValue == null) ? null : cachedValue.convertCacheToEntityDefaultInfo();
126    }
127
128    @Override
129        public EntityDefault getEntityDefaultFromArchiveByPrincipalName(String principalName) {
130        if (StringUtils.isBlank(principalName)) {
131            throw new IllegalArgumentException("principalName is blank");
132        }
133
134        Map<String,String> criteria = new HashMap<String, String>(1);
135        criteria.put("principalName", principalName);
136        Collection<EntityDefaultInfoCacheBo> entities = businessObjectService.findMatching(EntityDefaultInfoCacheBo.class, criteria);
137        return (entities == null || entities.isEmpty()) ? null : entities.iterator().next().convertCacheToEntityDefaultInfo();
138    }
139    
140    @Override
141    public EntityDefault getEntityDefaultFromArchiveByEmployeeId(String employeeId) {
142        if (StringUtils.isBlank(employeeId)) {
143            throw new IllegalArgumentException("employeeId is blank");
144        }
145        Map<String,String> criteria = new HashMap<String, String>(1);
146        criteria.put("employeeId", employeeId);
147        Collection<EntityDefaultInfoCacheBo> entities = businessObjectService.findMatching(EntityDefaultInfoCacheBo.class, criteria);
148        return (entities == null || entities.isEmpty()) ? null : entities.iterator().next().convertCacheToEntityDefaultInfo();
149    }
150    
151    @Override
152        public void saveEntityDefaultToArchive(EntityDefault entity) {
153        if (entity == null) {
154            throw new IllegalArgumentException("entity is blank");
155        }
156
157        // if the max size has been reached, schedule now
158        if (getMaxWriteQueueSize() <= writeQueue.offerAndGetSize(entity) /* <- this enqueues the KEDI */ &&
159                        writer.requestSubmit()) {
160                KSBServiceLocator.getThreadPool().execute(maxQueueSizeExceededWriter);
161        }
162    }
163
164    @Override
165    public void flushToArchive() {
166        writer.call();
167    }
168
169
170        public void setBusinessObjectService(BusinessObjectService businessObjectService) {
171                this.businessObjectService = businessObjectService;
172        }
173
174        public void setKualiConfigurationService(
175                        ConfigurationService kualiConfigurationService) {
176                this.kualiConfigurationService = kualiConfigurationService;
177        }
178
179    public void setTransactionManager(PlatformTransactionManager txMgr) {
180        this.transactionManager = txMgr;
181    }
182    
183    /** schedule the writer on the KSB scheduled pool. */
184        @Override
185        public void afterPropertiesSet() throws Exception {
186                LOG.info("scheduling writer...");
187                KSBServiceLocator.getScheduledPool().scheduleAtFixedRate(scheduledWriter,
188                                getExecutionIntervalSeconds(), getExecutionIntervalSeconds(), TimeUnit.SECONDS);
189        }
190
191        /** flush the write queue immediately. */
192        @Override
193        public void destroy() throws Exception {
194                KSBServiceLocator.getThreadPool().execute(shutdownWriter);
195        }
196
197        /**
198         * store the person to the database, but do this an alternate thread to
199         * prevent transaction issues since this service is non-transactional
200         *
201         * @author Kuali Rice Team (rice.collab@kuali.org)
202         *
203         */
204        private class EntityArchiveWriter implements Callable {
205
206                // flag used to prevent multiple processes from being submitted at once
207                AtomicBoolean currentlySubmitted = new AtomicBoolean(false);
208
209                private final Comparator<Comparable> nullSafeComparator = new Comparator<Comparable>() {
210                        @Override
211                        public int compare(Comparable i1, Comparable i2) {
212                                if (i1 != null && i2 != null) {
213                                        return i1.compareTo(i2);
214                                } else if (i1 == null) {
215                                        if (i2 == null) {
216                                                return 0;
217                                        } else {
218                                                return -1;
219                                        }
220                                } else { // if (entityId2 == null) {
221                                        return 1;
222                                }
223                        };
224                };
225
226                /**
227                 * Comparator that attempts to impose a total ordering on EntityDefault instances
228                 */
229                private final Comparator<EntityDefault> kediComparator = new Comparator<EntityDefault>() {
230                        /**
231                         * compares by entityId value
232                         * @see java.util.Comparator#compare(java.lang.Object, java.lang.Object)
233                         */
234                        @Override
235                        public int compare(EntityDefault o1, EntityDefault o2) {
236                                String entityId1 = (o1 == null) ? null : o1.getEntityId();
237                                String entityId2 = (o2 == null) ? null : o2.getEntityId();
238
239                                int result = nullSafeComparator.compare(entityId1, entityId2);
240
241                                if (result == 0) {
242                                        result = getPrincipalIdsString(o1).compareTo(getPrincipalIdsString(o2));
243                                }
244
245                                return result;
246                        }
247
248                        /**
249                         * This method builds a newline delimited String containing the identity's principal IDs in sorted order
250                         *
251                         * @param entity
252                         * @return
253                         */
254                        private String getPrincipalIdsString(EntityDefault entity) {
255                                String result = "";
256                                if (entity != null) {
257                                        List<Principal> principals = entity.getPrincipals();
258                                        if (principals != null) {
259                                                if (principals.size() == 1) { // one
260                                                        result = principals.get(0).getPrincipalId();
261                                                } else { // multiple
262                                                        String [] ids = new String [principals.size()];
263                                                        int insertIndex = 0;
264                                                        for (Principal principal : principals) {
265                                                                ids[insertIndex++] = principal.getPrincipalId();
266                                                        }
267                                                        Arrays.sort(ids);
268                                                        result = StringUtils.join(ids, "\n");
269                                                }
270                                        }
271                                }
272                                return result;
273                        }
274                };
275
276                public boolean requestSubmit() {
277                        return currentlySubmitted.compareAndSet(false, true);
278                }
279
280                /**
281                 * Call that tries to flush the write queue.
282                 * @see Callable#call()
283                 */
284                @Override
285                public Object call() {
286                        try {
287                                // the strategy is to grab chunks of entities, dedupe & sort them, and insert them in a big
288                                // batch to reduce transaction overhead.  Sorting is done so insertion order is guaranteed, which
289                                // prevents deadlocks between concurrent writers to the database.
290                                TransactionTemplate template = new TransactionTemplate(transactionManager);
291                                template.execute(new TransactionCallback() {
292                                        @Override
293                                        public Object doInTransaction(TransactionStatus status) {
294                                                EntityDefault entity = null;
295                                                ArrayList<EntityDefault> entitiesToInsert = new ArrayList<EntityDefault>(getMaxWriteQueueSize());
296                                                Set<String> deduper = new HashSet<String>(getMaxWriteQueueSize());
297
298                                                // order is important in this conditional so that elements aren't dequeued and then ignored
299                                                while (entitiesToInsert.size() < getMaxWriteQueueSize() && null != (entity = writeQueue.poll())) {
300                                                        if (deduper.add(entity.getEntityId())) {
301                                                                entitiesToInsert.add(entity);
302                                                        }
303                                                }
304
305                                                Collections.sort(entitiesToInsert, kediComparator);
306                        List<EntityDefaultInfoCacheBo> entityCache = new ArrayList<EntityDefaultInfoCacheBo>(entitiesToInsert.size());
307                        for (EntityDefault entityToInsert : entitiesToInsert) {
308                            entityCache.add(new EntityDefaultInfoCacheBo( entityToInsert ));
309                        }
310                        businessObjectService.save(entityCache);
311                                                //for (EntityDefault entityToInsert : entitiesToInsert) {
312                                                //      businessObjectService.save( new EntityDefaultInfoCacheBo( entityToInsert ) );
313                                                //}
314                                                return null;
315                                        }
316                                });
317                        } finally { // make sure our running flag is unset, otherwise we'll never run again
318                                currentlySubmitted.compareAndSet(true, false);
319                        }
320
321                        return Boolean.TRUE;
322                }
323        }
324
325        /**
326         * A class encapsulating a {@link ConcurrentLinkedQueue} and an {@link AtomicInteger} to
327         * provide fast offer(enqueue)/poll(dequeue) and size checking.  Size may be approximate due to concurrent
328         * activity, but for our purposes that is fine.
329         *
330         * @author Kuali Rice Team (rice.collab@kuali.org)
331         *
332         */
333        private static class WriteQueue {
334                AtomicInteger writeQueueSize = new AtomicInteger(0);
335                ConcurrentLinkedQueue<EntityDefault> queue = new ConcurrentLinkedQueue<EntityDefault>();
336
337                public int offerAndGetSize(EntityDefault entity) {
338                        queue.add(entity);
339                        return writeQueueSize.incrementAndGet();
340                }
341
342                private EntityDefault poll() {
343                        EntityDefault result = queue.poll();
344                        if (result != null) { writeQueueSize.decrementAndGet(); }
345                        return result;
346                }
347        }
348
349        /**
350         * decorator for a callable to log a message before it is executed
351         *
352         * @author Kuali Rice Team (rice.collab@kuali.org)
353         *
354         */
355        private static class PreLogCallableWrapper<A> implements Callable<A> {
356                
357                private final Callable inner;
358                private final Level level;
359                private final String message;
360
361                public PreLogCallableWrapper(Callable inner, Level level, String message) {
362                        this.inner = inner;
363                        this.level = level;
364                        this.message = message;
365                }
366
367                /**
368                 * logs the message then calls the inner Callable
369                 * 
370                 * @see java.util.concurrent.Callable#call()
371                 */
372                @Override
373                @SuppressWarnings("unchecked")
374                public A call() throws Exception {
375                        LOG.log(level, message);
376                        return (A)inner.call();
377                }
378        }
379
380        /**
381         * Adapts a Callable to be Runnable
382         *
383         * @author Kuali Rice Team (rice.collab@kuali.org)
384         *
385         */
386        private static class CallableAdapter implements Runnable {
387
388                private final Callable callable;
389
390                public CallableAdapter(Callable callable) {
391                        this.callable = callable;
392                }
393
394                @Override
395                public void run() {
396                        try {
397                                callable.call();
398                        } catch (Exception e) {
399                                throw new RuntimeException(e);
400                        }
401                }
402        }
403}