/* * Copyright (c) 2013 Google Inc. * * 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.google.cloud.backend.spi; import com.google.appengine.api.datastore.Cursor; import com.google.appengine.api.datastore.DatastoreService; import com.google.appengine.api.datastore.DatastoreServiceFactory; import com.google.appengine.api.datastore.Entity; import com.google.appengine.api.datastore.EntityNotFoundException; import com.google.appengine.api.datastore.FetchOptions; import com.google.appengine.api.datastore.Key; import com.google.appengine.api.datastore.KeyFactory; import com.google.appengine.api.datastore.Query; import com.google.appengine.api.datastore.Query.FilterOperator; import com.google.appengine.api.datastore.QueryResultIterable; import com.google.appengine.api.memcache.MemcacheService; import com.google.appengine.api.memcache.MemcacheServiceFactory; import com.google.appengine.api.taskqueue.Queue; import com.google.appengine.api.taskqueue.QueueFactory; import com.google.appengine.api.taskqueue.TaskOptions; import com.google.cloud.backend.config.StringUtility; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TimeZone; /** * Handles the persistence of each device and Perspective Search API subscription. */ public class DeviceSubscription { private final MemcacheService memcacheService; private final DatastoreService datastoreService; private final Gson gson; static final String PROPERTY_DEVICE_TYPE = "DeviceType"; static final String PROPERTY_ID = "DeviceID"; static final String PROPERTY_SUBSCRIPTION_IDS = "SubscriptionIDs"; static final int BATCH_DELETE_SIZE = 250; /** * Time stamp property name of the Device Subscription entity. */ public static final String PROPERTY_TIMESTAMP = "TimeStamp"; private static final Type setType = new TypeToken<Set<String>>() {}.getType(); /** * Device Subscription entity name. */ public static final String SUBSCRIPTION_KIND = "_DeviceSubscription"; /** * Default constructor for DeviceSubscription class. */ public DeviceSubscription() { this(DatastoreServiceFactory.getDatastoreService(), MemcacheServiceFactory.getMemcacheService()); } /** * Constructor for DeviceSubscription class. * * @param datastoreService AppEngine datastore service * @param memcacheService AppEngine memcache service */ public DeviceSubscription(DatastoreService datastoreService, MemcacheService memcacheService) { if (datastoreService == null || memcacheService == null) { throw new IllegalArgumentException("datastoreService and memcacheService cannot be null."); } this.datastoreService = datastoreService; this.memcacheService = memcacheService; this.gson = new Gson(); } /** * Returns an entity with device subscription information from memcache or datastore based on the * provided deviceId. * * @param deviceId A unique device identifier * @return an entity with device subscription information; or null when no corresponding * information found */ public Entity get(String deviceId) { if (StringUtility.isNullOrEmpty(deviceId)) { throw new IllegalArgumentException("DeviceId cannot be null or empty"); } Key key = getKey(deviceId); Entity entity = (Entity) this.memcacheService.get(key); // Get from datastore if unable to get data from cache if (entity == null) { try { entity = this.datastoreService.get(key); } catch (EntityNotFoundException e) { return null; } } return entity; } /** * Returns a set of subscriptions subscribed from the device. * * @param deviceId A unique device identifier */ public Set<String> getSubscriptionIds(String deviceId) { if (StringUtility.isNullOrEmpty(deviceId)) { return new HashSet<String>(); } Entity deviceSubscription = get(deviceId); if (deviceSubscription == null) { return new HashSet<String>(); } String subscriptionString = (String) deviceSubscription.getProperty(PROPERTY_SUBSCRIPTION_IDS); if (StringUtility.isNullOrEmpty(subscriptionString)) { return new HashSet<String>(); } return this.gson.fromJson(subscriptionString, setType); } /** * Creates an entity to persist a subscriptionID subscribed by a specific device. * * @param deviceType device type according to platform * @param deviceId unique device identifier * @param subscriptionId subscription identifier subscribed by this specific device * @return a datastore entity */ public Entity create(SubscriptionUtility.MobileType deviceType, String deviceId, String subscriptionId) { if (StringUtility.isNullOrEmpty(deviceId) || StringUtility.isNullOrEmpty(subscriptionId)) { return null; } Key key; String newDeviceId = SubscriptionUtility.extractRegId(deviceId); Entity deviceSubscription = get(newDeviceId); // Subscriptions is a "set" instead of a "list" to ensure uniqueness of each subscriptionId // for a device Set<String> subscriptions = new HashSet<String>(); if (deviceSubscription == null) { // Create a brand new one key = getKey(newDeviceId); deviceSubscription = new Entity(key); deviceSubscription.setProperty(PROPERTY_ID, newDeviceId); deviceSubscription.setProperty(PROPERTY_DEVICE_TYPE, deviceType.toString()); } else { key = deviceSubscription.getKey(); // Update the existing subscription list String ids = (String) deviceSubscription.getProperty(PROPERTY_SUBSCRIPTION_IDS); if (!StringUtility.isNullOrEmpty(ids)) { subscriptions = this.gson.fromJson(ids, setType); } } // Update entity subscription property and save only when subscriptionId has successfully added // to the subscriptions "set". If a subscriptionId is a duplicate of an existing subscription // in the set, we don't save this duplicated value into the entity. if (subscriptions.add(subscriptionId)) { deviceSubscription.setProperty(PROPERTY_SUBSCRIPTION_IDS, this.gson.toJson(subscriptions)); Calendar time = Calendar.getInstance(TimeZone.getTimeZone("UTC")); deviceSubscription.setProperty(PROPERTY_TIMESTAMP, time.getTime()); this.datastoreService.put(deviceSubscription); this.memcacheService.put(key, deviceSubscription); } return deviceSubscription; } /** * Deletes an entity corresponding to the provided deviceId. * * @param deviceId the device id for which all subscription information are to be deleted */ public void delete(String deviceId) { if (StringUtility.isNullOrEmpty(deviceId)) { throw new IllegalArgumentException("deviceId cannot be null or empty."); } Key key = getKey(deviceId); this.datastoreService.delete(key); this.memcacheService.delete(key); } private void deleteInBatch(List<Key> keys) { this.memcacheService.deleteAll(keys); this.datastoreService.delete(keys); } /** * Deletes all device subscription entities continuously using task push queue. * * @param time Threshold time before which entities created will be deleted. If time is null, * current time is used and set as Threshold time. * @param cursor Query cursor indicates last query result set position */ protected void deleteAllContinuously(Date time, String cursor) { if (time == null) { time = Calendar.getInstance(TimeZone.getTimeZone("UTC")).getTime(); } Query.FilterPredicate timeFilter = new Query.FilterPredicate(PROPERTY_TIMESTAMP, FilterOperator.LESS_THAN_OR_EQUAL, time); QueryResultIterable<Entity> entities; List<Key> keys = new ArrayList<Key> (); List<String> subIds = new ArrayList<String> (); Query queryAll; queryAll = new Query(DeviceSubscription.SUBSCRIPTION_KIND).setFilter(timeFilter); FetchOptions options = FetchOptions.Builder.withLimit(BATCH_DELETE_SIZE); if (!StringUtility.isNullOrEmpty(cursor)) { options.startCursor(Cursor.fromWebSafeString(cursor)); } entities = this.datastoreService.prepare(queryAll).asQueryResultIterable(options); if (entities != null && entities.iterator() != null) { for (Entity entity : entities) { keys.add(entity.getKey()); String[] ids = new Gson().fromJson((String) entity.getProperty(PROPERTY_SUBSCRIPTION_IDS), String[].class); subIds.addAll(Arrays.asList(ids)); } } if (keys.size() > 0) { deleteInBatch(keys); enqueueDeleteDeviceSubscription(time, entities.iterator().getCursor().toWebSafeString()); } if (subIds.size() > 0) { deletePsiSubscriptions(subIds); } } /** * Delete Prospective Search Api subscriptions in batches. * * @param subIds A list of Prospective Search Api subscription ids to be deleted. */ private void deletePsiSubscriptions(List<String> subIds) { int size; int current = 0; do { size = Math.min(subIds.size() - current, BATCH_DELETE_SIZE); List<String> newList = new ArrayList<String>(subIds.subList(current, current + size)); current += size; SubscriptionUtility.enqueueDeletePsiSubscription(newList.toArray(new String[size])); } while (subIds.size() > current); } /** * Enqueues device subscription entity to be deleted. * * @param time Threshold time before which entities created will be deleted * @param cursor Query cursor indicates query result position */ protected void enqueueDeleteDeviceSubscription(Date time, String cursor) { Queue deviceTokenCleanupQueue = QueueFactory.getQueue("subscription-removal"); deviceTokenCleanupQueue.add(TaskOptions.Builder.withMethod(TaskOptions.Method.POST) .url("/admin/push/devicesubscription/delete") .param("timeStamp", new Gson().toJson(time, Date.class)) .param("cursor", cursor) .param("type", SubscriptionUtility.REQUEST_TYPE_DEVICE_SUB)); } /** * Enqueues device subscription entity to be deleted with initializing arguments. * * @param time Threshold time before which entities created will be deleted * @param cursor Query cursor indicates query result position */ protected void enqueueDeleteDeviceSubscription() { enqueueDeleteDeviceSubscription(null, ""); } /** * Gets a key for subscription kind entity based on device id. * * @param deviceId A unique device identifier */ protected Key getKey(String deviceId) { if (StringUtility.isNullOrEmpty(deviceId)) { throw new IllegalArgumentException("deviceId cannot be null or empty"); } else { return KeyFactory.createKey(SUBSCRIPTION_KIND, deviceId); } } }