/* * Copyright (C) 2017 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.runtimes.tomcat.session; import com.google.cloud.datastore.Datastore; import com.google.cloud.datastore.DatastoreOptions; import com.google.cloud.datastore.Entity; import com.google.cloud.datastore.FullEntity; import com.google.cloud.datastore.Key; import com.google.cloud.datastore.KeyFactory; import com.google.cloud.datastore.PathElement; import com.google.cloud.datastore.Query; import com.google.cloud.datastore.QueryResults; import com.google.cloud.datastore.StructuredQuery.PropertyFilter; import com.google.cloud.runtimes.tomcat.session.DatastoreSession.SessionMetadata; import com.google.cloud.trace.Trace; import com.google.cloud.trace.Tracer; import com.google.cloud.trace.core.TraceContext; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; import com.google.common.collect.Streams; import java.io.IOException; import java.time.Clock; import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.stream.Stream; import org.apache.catalina.LifecycleException; import org.apache.catalina.Session; import org.apache.catalina.session.StoreBase; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; /** * This store interacts with the Datastore service to persist sessions. * * <p>It does not make any assumptions about the manager, so it could be used * by all manager implementations.</p> * * <p>However, aggregations can be slow on the Datastore. So, if performance is a concern, prefer * using a manager implementation which is not using aggregations such as * {@link DatastoreManager}</p> */ public class DatastoreStore extends StoreBase { private static final Log log = LogFactory.getLog(DatastoreStore.class); private Datastore datastore = null; /** * Name of the kind used in The Datastore for the session. */ private String sessionKind; /** * Namespace to use in the Datastore. */ private String namespace; /** * Whether or not to send traces to Stackdriver for the operations related to session persistence. */ private boolean traceRequest = false; private Clock clock; /** * {@inheritDoc} * * <p>Initiate a connection to the Datastore.</p> * */ @Override protected synchronized void startInternal() throws LifecycleException { log.debug("Initialization of the Datastore Store"); this.clock = Clock.systemUTC(); this.datastore = DatastoreOptions.newBuilder().setNamespace(namespace).build().getService(); super.startInternal(); } private Key newKey(String name) { return datastore.newKeyFactory().setKind(sessionKind).newKey(name); } /** * Return the number of Sessions present in this Store. * * <p>The Datastore does not support counting elements in a collection. * So, all keys are fetched and the counted locally.</p> * * <p>This method may be slow if a large number of sessions are persisted, * prefer operations on individual entities rather than aggregations.</p> * * @return The number of sessions stored into the Datastore */ @Override public int getSize() throws IOException { log.debug("Accessing sessions count, be cautious this operation can cause performance issues"); Query<Key> query = Query.newKeyQueryBuilder().build(); QueryResults<Key> results = datastore.run(query); long count = Streams.stream(results).count(); return Math.toIntExact(count); } /** * Returns an array containing the session identifiers of all Sessions currently saved in this * Store. If there are no such Sessions, a zero-length array is returned. * * <p>This operation may be slow if a large number of sessions is persisted. * Note that the number of keys returned may be bounded by the Datastore configuration.</p> * * @return The ids of all persisted sessions */ @Override public String[] keys() throws IOException { String[] keys; Query<Key> query = Query.newKeyQueryBuilder().build(); QueryResults<Key> results = datastore.run(query); keys = Streams.stream(results) .map(key -> key.getNameOrId().toString()) .toArray(String[]::new); if (keys == null) { keys = new String[0]; } return keys; } /** * Load and return the Session associated with the specified session identifier from this Store, * without removing it. If there is no such stored Session, return null. * * <p>Look in the Datastore for a serialized session and attempt to deserialize it.</p> * * <p>If the session is successfully deserialized, it is added to the current manager and is * returned by this method. Otherwise null is returned.</p> * * @param id Session identifier of the session to load * @return The loaded session instance * @throws ClassNotFoundException If a deserialization error occurs */ @Override public Session load(String id) throws ClassNotFoundException, IOException { log.debug("Session " + id + " requested"); TraceContext context = startSpan("Loading session"); Key sessionKey = newKey(id); DatastoreSession session = deserializeSession(sessionKey); endSpan(context); log.debug("Session " + id + " loaded"); return session; } /** * Create a new session usable by Tomcat, from a serialized session in a Datastore Entity. * @param sessionKey The key associated with the session metadata and attributes. * @return A new session containing the metadata and attributes stored in the entity. * @throws ClassNotFoundException Thrown if a class serialized in the entity is not available in * this context. * @throws IOException Thrown when an error occur during the deserialization. */ private DatastoreSession deserializeSession(Key sessionKey) throws ClassNotFoundException, IOException { TraceContext loadingSessionContext = startSpan("Fetching the session from Datastore"); Iterator<Entity> entities = datastore.run(Query.newEntityQueryBuilder() .setKind(sessionKind) .setFilter(PropertyFilter.hasAncestor(sessionKey)) .build()); endSpan(loadingSessionContext); DatastoreSession session = null; if (entities.hasNext()) { session = (DatastoreSession) manager.createEmptySession(); TraceContext deserializationContext = startSpan("Deserialization of the session"); session.restoreFromEntities(sessionKey, Lists.newArrayList(entities)); endSpan(deserializationContext); } return session; } /** * Remove the Session with the specified session identifier from this Store. * If no such Session is present, this method takes no action. * * @param id Session identifier of the session to remove */ @Override public void remove(String id) { log.debug("Removing session: " + id); datastore.delete(newKey(id)); } /** * Remove all Sessions from this Store. */ @Override public void clear() throws IOException { log.debug("Deleting all sessions"); datastore.delete(Arrays.stream(keys()) .map(this::newKey) .toArray(Key[]::new)); } /** * Save the specified Session into this Store. Any previously saved information for * the associated session identifier is replaced. * * <p>Attempt to serialize the session and send it to the datastore.</p> * * @throws IOException If an error occurs during the serialization of the session. * * @param session Session to be saved */ @Override public void save(Session session) throws IOException { log.debug("Persisting session: " + session.getId()); if (!(session instanceof DatastoreSession)) { throw new IOException( "The session must be an instance of DatastoreSession to be serialized"); } DatastoreSession datastoreSession = (DatastoreSession) session; Key sessionKey = newKey(session.getId()); KeyFactory attributeKeyFactory = datastore.newKeyFactory() .setKind(sessionKind) .addAncestor(PathElement.of(sessionKind, sessionKey.getName())); List<Entity> entities = serializeSession(datastoreSession, sessionKey, attributeKeyFactory); TraceContext datastoreSaveContext = startSpan("Storing the session in the Datastore"); datastore.put(entities.toArray(new FullEntity[0])); datastore.delete(datastoreSession.getSuppressedAttributes().stream() .map(attributeKeyFactory::newKey) .toArray(Key[]::new)); endSpan(datastoreSaveContext); } /** * Serialize a session to a list of Entities that can be stored to the Datastore. * @param session The session to serialize. * @return A list of one or more entities containing the session and its attributes. * @throws IOException If the session cannot be serialized. */ @VisibleForTesting List<Entity> serializeSession(DatastoreSession session, Key sessionKey, KeyFactory attributeKeyFactory) throws IOException { TraceContext serializationContext = startSpan("Serialization of the session"); List<Entity> entities = session.saveToEntities(sessionKey, attributeKeyFactory); endSpan(serializationContext); return entities; } /** * Remove expired sessions from the datastore. */ @Override public void processExpires() { log.debug("Processing expired sessions"); Query<Key> query = Query.newKeyQueryBuilder().setKind(sessionKind) .setFilter(PropertyFilter.le(SessionMetadata.EXPIRATION_TIME, clock.millis())) .build(); QueryResults<Key> keys = datastore.run(query); Stream<Key> toDelete = Streams.stream(keys) .parallel() .flatMap(key -> Streams.stream(datastore.run(Query.newKeyQueryBuilder() .setKind(sessionKind) .setFilter(PropertyFilter.hasAncestor(newKey(key.getName()))) .build()))); datastore.delete(toDelete.toArray(Key[]::new)); } @VisibleForTesting TraceContext startSpan(String spanName) { TraceContext context = null; if (traceRequest) { context = Trace.getTracer().startSpan(spanName); } return context; } @VisibleForTesting private void endSpan(TraceContext context) { if (context != null) { Tracer tracer = Trace.getTracer(); tracer.endSpan(context); } } /** * This property will be injected by Tomcat on startup. * * <p>See context.xml and catalina.properties for the default values</p> */ public void setNamespace(String namespace) { this.namespace = namespace; } /** * This property will be injected by Tomcat on startup. */ public void setSessionKind(String sessionKind) { this.sessionKind = sessionKind; } /** * This property will be injected by Tomcat on startup. */ public void setTraceRequest(boolean traceRequest) { this.traceRequest = traceRequest; } @VisibleForTesting void setDatastore(Datastore datastore) { this.datastore = datastore; } @VisibleForTesting void setClock(Clock clock) { this.clock = clock; } }