org.eclipse.jetty.nosql.mongodb.MongoSessionDataStore.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.jetty.nosql.mongodb.MongoSessionDataStore.java

Source

//
//  ========================================================================
//  Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//

package org.eclipse.jetty.nosql.mongodb;

import com.mongodb.BasicDBList;
import com.mongodb.BasicDBObject;
import com.mongodb.BasicDBObjectBuilder;
import com.mongodb.DBCollection;
import com.mongodb.DBCursor;
import com.mongodb.DBObject;
import com.mongodb.MongoException;
import com.mongodb.WriteConcern;
import com.mongodb.WriteResult;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import org.eclipse.jetty.nosql.NoSqlSessionDataStore;
import org.eclipse.jetty.server.session.SessionData;
import org.eclipse.jetty.util.ClassLoadingObjectInputStream;
import org.eclipse.jetty.util.annotation.ManagedAttribute;
import org.eclipse.jetty.util.annotation.ManagedObject;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;

/**
 * MongoSessionDataStore
 *
 *  The document model is an outer object that contains the elements:
 * <ul>
 *  <li>"id"      : session_id </li>
 *  <li>"created" : create_time </li>
 *  <li>"accessed": last_access_time </li>
 *  <li>"maxIdle" : max_idle_time setting as session was created </li>
 *  <li>"expiry"  : time at which session should expire </li>
 *  <li>"valid"   : session_valid </li>
 *  <li>"context" : a nested object containing 1 nested object per context for which the session id is in use
 * </ul>
 * Each of the nested objects inside the "context" element contains:
 * <ul>
 *  <li>unique_context_name : nested object containing name:value pairs of the session attributes for that context</li>
 *  <li>unique_context_name: vhost:contextpath, where no vhosts="0_0_0_0", root context = "", contextpath "/" replaced by "_"
 * </ul>
 * <p>
 * One of the name:value attribute pairs will always be the special attribute "__metadata__". The value 
 * is an object representing a version counter which is incremented every time the attributes change.
 * </p>
 * <p>
 * For example:
 * <pre>
 * { "_id"       : ObjectId("52845534a40b66410f228f23"), 
 *    "accessed" :  NumberLong("1384818548903"), 
 *    "maxIdle"  : 1,
 *    "context"  : { "0_0_0_0:_testA" : { "A"            : "A", 
 *                                     "__metadata__" : { "version" : NumberLong(2) } 
 *                                   },
 *                   "0_0_0_0:_testB" : { "B"            : "B", 
 *                                     "__metadata__" : { "version" : NumberLong(1) } 
 *                                   } 
 *                 }, 
 *    "created"  : NumberLong("1384818548903"),
 *    "expiry"   : NumberLong("1384818549903"),
 *    "id"       : "w01ijx2vnalgv1sqrpjwuirprp7", 
 *    "valid"    : true 
 * }
 * </pre>
 * <p>
 * In MongoDB, the nesting level is indicated by "." separators for the key name. Thus to
 * interact with a session attribute, the key is composed of:
 * <code>"context".unique_context_name.attribute_name</code>
 *  Eg  <code>"context"."0_0_0_0:_testA"."A"</code>
 *  
 * 
 */
@ManagedObject
public class MongoSessionDataStore extends NoSqlSessionDataStore {

    private final static Logger LOG = Log.getLogger("org.eclipse.jetty.server.session");

    /**
     * Special attribute for a session that is context-specific
     */
    private final static String __METADATA = "__metadata__";

    /**
     * Name of nested document field containing 1 sub document per context for which the session id is in use
     */
    private final static String __CONTEXT = "context";

    /**
     * Special attribute per session per context, incremented each time attributes are modified
     */
    public final static String __VERSION = __METADATA + ".version";

    public final static String __LASTSAVED = __METADATA + ".lastSaved";

    public final static String __LASTNODE = __METADATA + ".lastNode";

    /**
     * Last access time of session
     */
    public final static String __ACCESSED = "accessed";

    /**
     * Time this session will expire, based on last access time and maxIdle
     */
    public final static String __EXPIRY = "expiry";

    /**
     * The max idle time of a session (smallest value across all contexts which has a session with the same id)
     */
    public final static String __MAX_IDLE = "maxIdle";

    /**
     * Time of session creation
     */
    private final static String __CREATED = "created";

    /**
     * Whether or not session is valid
     */
    public final static String __VALID = "valid";

    /**
     * Session id
     */
    public final static String __ID = "id";

    /**
     * Utility value of 1 for a session version for this context
     */
    private DBObject _version_1;

    /**
     * Access to MongoDB
     */
    private DBCollection _dbSessions;

    public void setDBCollection(DBCollection collection) {
        _dbSessions = collection;
    }

    @ManagedAttribute(value = "DBCollection", readonly = true)
    public DBCollection getDBCollection() {
        return _dbSessions;
    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionDataStore#load(String)
     */
    @Override
    public SessionData load(String id) throws Exception {
        final AtomicReference<SessionData> reference = new AtomicReference<SessionData>();
        final AtomicReference<Exception> exception = new AtomicReference<Exception>();
        Runnable r = new Runnable() {
            public void run() {
                try {
                    DBObject sessionDocument = _dbSessions.findOne(new BasicDBObject(__ID, id));

                    if (LOG.isDebugEnabled())
                        LOG.debug("id={} loaded={}", id, sessionDocument);

                    if (sessionDocument == null)
                        return;

                    Boolean valid = (Boolean) sessionDocument.get(__VALID);

                    if (LOG.isDebugEnabled())
                        LOG.debug("id={} valid={}", id, valid);
                    if (valid == null || !valid)
                        return;

                    Object version = getNestedValue(sessionDocument, getContextSubfield(__VERSION));
                    Long lastSaved = (Long) getNestedValue(sessionDocument, getContextSubfield(__LASTSAVED));
                    String lastNode = (String) getNestedValue(sessionDocument, getContextSubfield(__LASTNODE));

                    Long created = (Long) sessionDocument.get(__CREATED);
                    Long accessed = (Long) sessionDocument.get(__ACCESSED);
                    Long maxInactive = (Long) sessionDocument.get(__MAX_IDLE);
                    Long expiry = (Long) sessionDocument.get(__EXPIRY);

                    NoSqlSessionData data = null;

                    // get the session for the context
                    DBObject sessionSubDocumentForContext = (DBObject) getNestedValue(sessionDocument,
                            getContextField());

                    if (LOG.isDebugEnabled())
                        LOG.debug("attrs {}", sessionSubDocumentForContext);

                    if (sessionSubDocumentForContext != null) {
                        if (LOG.isDebugEnabled())
                            LOG.debug("Session {} present for context {}", id, _context);

                        //only load a session if it exists for this context
                        data = (NoSqlSessionData) newSessionData(id, created, accessed, accessed, maxInactive);
                        data.setVersion(version);
                        data.setExpiry(expiry);
                        data.setContextPath(_context.getCanonicalContextPath());
                        data.setVhost(_context.getVhost());
                        data.setLastSaved(lastSaved);
                        data.setLastNode(lastNode);

                        HashMap<String, Object> attributes = new HashMap<>();
                        for (String name : sessionSubDocumentForContext.keySet()) {
                            //skip special metadata attribute which is not one of the actual session attributes
                            if (__METADATA.equals(name))
                                continue;
                            String attr = decodeName(name);
                            Object value = decodeValue(sessionSubDocumentForContext.get(name));
                            attributes.put(attr, value);
                        }

                        data.putAllAttributes(attributes);
                    } else {
                        if (LOG.isDebugEnabled())
                            LOG.debug("Session  {} not present for context {}", id, _context);
                    }

                    reference.set(data);
                } catch (Exception e) {
                    exception.set(e);
                }
            }
        };

        _context.run(r);

        if (exception.get() != null)
            throw exception.get();

        return reference.get();
    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionDataStore#delete(String)
     */
    @Override
    public boolean delete(String id) throws Exception {
        if (LOG.isDebugEnabled())
            LOG.debug("Remove:session {} for context ", id, _context);

        /*
         * Check if the session exists and if it does remove the context
         * associated with this session
         */
        BasicDBObject mongoKey = new BasicDBObject(__ID, id);

        //DBObject sessionDocument = _dbSessions.findOne(mongoKey,_version_1);
        DBObject sessionDocument = _dbSessions.findOne(new BasicDBObject(__ID, id));

        if (sessionDocument != null) {
            DBObject c = (DBObject) getNestedValue(sessionDocument, __CONTEXT);
            if (c == null) {
                //delete whole doc
                _dbSessions.remove(mongoKey, WriteConcern.SAFE);
                return false;
            }

            Set<String> contexts = c.keySet();
            if (contexts.isEmpty()) {
                //delete whole doc
                _dbSessions.remove(mongoKey, WriteConcern.SAFE);
                return false;
            }

            if (contexts.size() == 1 && contexts.iterator().next().equals(getCanonicalContextId())) {
                //delete whole doc
                _dbSessions.remove(new BasicDBObject(__ID, id), WriteConcern.SAFE);
                return true;
            }

            //just remove entry for my context
            BasicDBObject remove = new BasicDBObject();
            BasicDBObject unsets = new BasicDBObject();
            unsets.put(getContextField(), 1);
            remove.put("$unset", unsets);
            WriteResult result = _dbSessions.update(mongoKey, remove, false, false, WriteConcern.SAFE);
            return true;
        } else {
            return false;
        }

    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionDataStore#exists(java.lang.String)
     */
    @Override
    public boolean exists(String id) throws Exception {
        DBObject fields = new BasicDBObject();
        fields.put(__EXPIRY, 1);
        fields.put(__VALID, 1);

        DBObject sessionDocument = _dbSessions.findOne(new BasicDBObject(__ID, id), fields);

        if (sessionDocument == null)
            return false; //doesn't exist

        Boolean valid = (Boolean) sessionDocument.get(__VALID);
        if (!valid)
            return false; //invalid - nb should not happen

        Long expiry = (Long) sessionDocument.get(__EXPIRY);

        if (expiry.longValue() <= 0)
            return true; //never expires, its good
        return (expiry.longValue() > System.currentTimeMillis()); //expires later
    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionDataStore#getExpired(Set)
     */
    @Override
    public Set<String> doGetExpired(Set<String> candidates) {
        long now = System.currentTimeMillis();
        long upperBound = now;
        Set<String> expiredSessions = new HashSet<>();

        //firstly ask mongo to verify if these candidate ids have expired - all of
        //these candidates will be for our node
        BasicDBObject query = new BasicDBObject();
        query.append(__ID, new BasicDBObject("$in", candidates));
        query.append(__EXPIRY, new BasicDBObject("$gt", 0).append("$lt", upperBound));

        DBCursor verifiedExpiredSessions = null;
        try {
            verifiedExpiredSessions = _dbSessions.find(query, new BasicDBObject(__ID, 1));
            for (DBObject session : verifiedExpiredSessions) {
                String id = (String) session.get(__ID);
                if (LOG.isDebugEnabled())
                    LOG.debug("{} Mongo confirmed expired session {}", _context, id);
                expiredSessions.add(id);
            }
        } finally {
            if (verifiedExpiredSessions != null)
                verifiedExpiredSessions.close();
        }

        //now ask mongo to find sessions last managed by any nodes that expired a while ago 
        //if this is our first expiry check, make sure that we only grab really old sessions
        if (_lastExpiryCheckTime <= 0)
            upperBound = (now - (3 * (1000L * _gracePeriodSec)));
        else
            upperBound = _lastExpiryCheckTime - (1000L * _gracePeriodSec);

        query = new BasicDBObject();
        BasicDBObject gt = new BasicDBObject(__EXPIRY, new BasicDBObject("$gt", 0));
        BasicDBObject lt = new BasicDBObject(__EXPIRY, new BasicDBObject("$lt", upperBound));
        BasicDBList list = new BasicDBList();
        list.add(gt);
        list.add(lt);
        query.append("and", list);

        DBCursor oldExpiredSessions = null;
        try {
            BasicDBObject bo = new BasicDBObject(__ID, 1);
            bo.append(__EXPIRY, 1);

            oldExpiredSessions = _dbSessions.find(query, bo);
            for (DBObject session : oldExpiredSessions) {
                String id = (String) session.get(__ID);
                if (LOG.isDebugEnabled())
                    LOG.debug("{} Mongo found old expired session {}", _context,
                            id + " exp=" + session.get(__EXPIRY));
                expiredSessions.add(id);
            }

        } finally {
            oldExpiredSessions.close();
        }

        return expiredSessions;
    }

    /** 
     * @see org.eclipse.jetty.server.session.AbstractSessionDataStore#doStore(String, SessionData, long) 
     */
    @Override
    public void doStore(String id, SessionData data, long lastSaveTime) throws Exception {
        NoSqlSessionData nsqd = (NoSqlSessionData) data;

        // Form query for upsert
        BasicDBObject key = new BasicDBObject(__ID, id);

        // Form updates
        BasicDBObject update = new BasicDBObject();
        boolean upsert = false;
        BasicDBObject sets = new BasicDBObject();
        BasicDBObject unsets = new BasicDBObject();

        Object version = ((NoSqlSessionData) data).getVersion();

        // New session
        if (lastSaveTime <= 0) {
            upsert = true;
            version = new Long(1);
            sets.put(__CREATED, nsqd.getCreated());
            sets.put(__VALID, true);
            sets.put(getContextSubfield(__VERSION), version);
            sets.put(getContextSubfield(__LASTSAVED), data.getLastSaved());
            sets.put(getContextSubfield(__LASTNODE), data.getLastNode());
            sets.put(__MAX_IDLE, nsqd.getMaxInactiveMs());
            sets.put(__EXPIRY, nsqd.getExpiry());
            nsqd.setVersion(version);
        } else {
            sets.put(getContextSubfield(__LASTSAVED), data.getLastSaved());
            sets.put(getContextSubfield(__LASTNODE), data.getLastNode());
            version = new Long(((Number) version).longValue() + 1);
            nsqd.setVersion(version);
            update.put("$inc", _version_1);
            //if max idle time and/or expiry is smaller for this context, then choose that for the whole session doc
            BasicDBObject fields = new BasicDBObject();
            fields.append(__MAX_IDLE, true);
            fields.append(__EXPIRY, true);
            DBObject o = _dbSessions.findOne(new BasicDBObject("id", id), fields);
            if (o != null) {
                Long tmpLong = (Long) o.get(__MAX_IDLE);
                long currentMaxIdle = (tmpLong == null ? 0 : tmpLong.longValue());
                tmpLong = (Long) o.get(__EXPIRY);
                long currentExpiry = (tmpLong == null ? 0 : tmpLong.longValue());

                if (currentMaxIdle != nsqd.getMaxInactiveMs())
                    sets.put(__MAX_IDLE, nsqd.getMaxInactiveMs());

                if (currentExpiry != nsqd.getExpiry())
                    sets.put(__EXPIRY, nsqd.getExpiry());
            } else
                LOG.warn("Session {} not found, can't update", id);
        }

        sets.put(__ACCESSED, nsqd.getAccessed());

        Set<String> names = nsqd.takeDirtyAttributes();

        if (lastSaveTime <= 0) {
            names.addAll(nsqd.getAllAttributeNames()); // note dirty may include removed names
        }

        for (String name : names) {
            Object value = data.getAttribute(name);
            if (value == null)
                unsets.put(getContextField() + "." + encodeName(name), 1);
            else
                sets.put(getContextField() + "." + encodeName(name), encodeName(value));
        }

        // Do the upsert
        if (!sets.isEmpty())
            update.put("$set", sets);
        if (!unsets.isEmpty())
            update.put("$unset", unsets);
        WriteResult res = _dbSessions.update(key, update, upsert, false, WriteConcern.SAFE);
        if (LOG.isDebugEnabled())
            LOG.debug("Save:db.sessions.update( {}, {},{} )", key, update, res);
    }

    @Override
    protected void doStart() throws Exception {
        if (_dbSessions == null)
            throw new IllegalStateException("DBCollection not set");

        _version_1 = new BasicDBObject(getContextSubfield(__VERSION), 1);

        ensureIndexes();

        super.doStart();
    }

    @Override
    protected void doStop() throws Exception {
        super.doStop();
    }

    protected void ensureIndexes() throws MongoException {
        DBObject idKey = BasicDBObjectBuilder.start().add("id", 1).get();
        _dbSessions.createIndex(idKey, BasicDBObjectBuilder.start().add("name", "id_1")
                .add("ns", _dbSessions.getFullName()).add("sparse", false).add("unique", true).get());

        DBObject versionKey = BasicDBObjectBuilder.start().add("id", 1).add("version", 1).get();
        _dbSessions.createIndex(versionKey, BasicDBObjectBuilder.start().add("name", "id_1_version_1")
                .add("ns", _dbSessions.getFullName()).add("sparse", false).add("unique", true).get());
        //TODO perhaps index on expiry time?
    }

    /*------------------------------------------------------------ */
    private String getContextField() {
        return __CONTEXT + "." + getCanonicalContextId();
    }

    private String getCanonicalContextId() {
        return canonicalizeVHost(_context.getVhost()) + ":" + _context.getCanonicalContextPath();
    }

    private String canonicalizeVHost(String vhost) {
        if (vhost == null)
            return "";

        return vhost.replace('.', '_');
    }

    private String getContextSubfield(String attr) {
        return getContextField() + "." + attr;
    }

    /*------------------------------------------------------------ */
    protected Object decodeValue(final Object valueToDecode) throws IOException, ClassNotFoundException {
        if (valueToDecode == null || valueToDecode instanceof Number || valueToDecode instanceof String
                || valueToDecode instanceof Boolean || valueToDecode instanceof Date) {
            return valueToDecode;
        } else if (valueToDecode instanceof byte[]) {
            final byte[] decodeObject = (byte[]) valueToDecode;
            final ByteArrayInputStream bais = new ByteArrayInputStream(decodeObject);
            final ClassLoadingObjectInputStream objectInputStream = new ClassLoadingObjectInputStream(bais);
            return objectInputStream.readUnshared();
        } else if (valueToDecode instanceof DBObject) {
            Map<String, Object> map = new HashMap<String, Object>();
            for (String name : ((DBObject) valueToDecode).keySet()) {
                String attr = decodeName(name);
                map.put(attr, decodeValue(((DBObject) valueToDecode).get(name)));
            }
            return map;
        } else {
            throw new IllegalStateException(valueToDecode.getClass().toString());
        }
    }

    /*------------------------------------------------------------ */
    protected String decodeName(String name) {
        return name.replace("%2E", ".").replace("%25", "%");
    }

    /*------------------------------------------------------------ */
    protected String encodeName(String name) {
        return name.replace("%", "%25").replace(".", "%2E");
    }

    /*------------------------------------------------------------ */
    protected Object encodeName(Object value) throws IOException {
        if (value instanceof Number || value instanceof String || value instanceof Boolean
                || value instanceof Date) {
            return value;
        } else if (value.getClass().equals(HashMap.class)) {
            BasicDBObject o = new BasicDBObject();
            for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
                if (!(entry.getKey() instanceof String)) {
                    o = null;
                    break;
                }
                o.append(encodeName(entry.getKey().toString()), encodeName(entry.getValue()));
            }

            if (o != null)
                return o;
        }

        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(bout);
        out.reset();
        out.writeUnshared(value);
        out.flush();
        return bout.toByteArray();
    }

    /*------------------------------------------------------------ */
    /**
     * Dig through a given dbObject for the nested value
     */
    private Object getNestedValue(DBObject dbObject, String nestedKey) {
        String[] keyChain = nestedKey.split("\\.");

        DBObject temp = dbObject;

        for (int i = 0; i < keyChain.length - 1; ++i) {
            temp = (DBObject) temp.get(keyChain[i]);

            if (temp == null) {
                return null;
            }
        }

        return temp.get(keyChain[keyChain.length - 1]);
    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionDataStore#isPassivating()
     */
    @ManagedAttribute(value = "does store serialize sessions", readonly = true)
    @Override
    public boolean isPassivating() {
        return true;
    }

    /** 
     * @see org.eclipse.jetty.server.session.AbstractSessionDataStore#toString()
     */
    @Override
    public String toString() {
        return String.format("%s[collection=%s]", super.toString(), getDBCollection());
    }

}