I hate expired sessions, death to all expired sessions. Traditionally a Java servlet container has a fixed session time, a flood of traffic can potentially cause JVM OOM errors if the session time is set too high. I wanted a smart session container that can hold onto sessions for as long as possible and expire sessions only when it is absolutely necessary; A Memcached store would be perfect for this.

There for I recently open sourced the jetty-session-store to solve this problem. With the jetty-session-store you can save your session state to Ehcache, Memcached or the database. State should not be bound to a single JVM, Viva Shared Session Stores!

So now that jetty-session-store is out in the wild you can technically cluster Wicket using just the HttpSessionStore. However, it isn’t very efficient with the way Memcached allocates data in fixed sized cache buckets.

1. Wicket sessions under the HttpSessionStore can get quite large, well over 1Mb in size. A Wicket session not only stores the session state but also the previous serialized pages the user has visited.

2. Serializing and de-serializing a large data structure can get expensive. The HttpSessionStore retains an AccessStackPageMap, which is a list data structure consisting of multiple page map revisions.

So instead of saving one large AccessStackPageMap, I wrote a SecondLevelCacheSessionStore that saves a page map revision per cache entry. This leads to much better cache utilization and a whole lot less serialization on the wire. Not to mention this avoids the whole 1Mb Memcached size limit.

Before you go willy nilly with clustering, read the Wicket render strategies page. Wicket requires session affinity for buffered responses with the default rendering strategy.

Clustering Wicket has never been easier.

Here is an example on how to offload page maps to a hybrid EhCache/Memcached cache. Memcached for long term shared storage while EhCache for short-lived fast cache look ups.

public class WebApp extends WebApplication {
	@Override
	protected ISessionStore newSessionStore() {
		// localhost:11211 -- memcached server
		// "fabpagestore" -- unique appender to avoid key clashes.
		// 300 -- 5 minute TTL for local ehcache.
		return new SecondLevelCacheSessionStore(this,
				new CachePageStore(Arrays.asList("localhost:11211"),"fabpagestore",300));
	}
}

Here is an example on how to offload page maps to the database.

public class WebApp extends WebApplication {
	@Override
	protected ISessionStore newSessionStore() {
		// "fabpagestore" -- unique appender to avoid key clashes.
		return new SecondLevelCacheSessionStore(this,new CachePageStore(
				new DBCache("jdbc:mysql://foo/mydb", "myname", "mypass", "com.driver.Name", "fabpagestore")));
	}
}

Here is my CachePageStore;

package com.base.pagestore;

import com.base.cache.AsyncMemcache;
import com.base.cache.ICache;
import org.apache.wicket.Page;
import org.apache.wicket.protocol.http.SecondLevelCacheSessionStore.IClusteredPageStore;
import org.apache.wicket.protocol.http.pagestore.AbstractPageStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

public class CachePageStore extends AbstractPageStore implements IClusteredPageStore {
    private ICache cache;
    private Logger logger = LoggerFactory.getLogger(CachePageStore.class);

    public CachePageStore(final List servers, final String poolName, final int ttl) {
        this(servers, poolName, true, ttl);
    }

    public CachePageStore(final List servers, final String poolName, boolean async, final int ttl) {
        this(new AsyncMemcache(servers, poolName, async, ttl));
    }

    public CachePageStore(final ICache cache) {
        this.cache = cache;
    }

    // If pageVersion -1 must return highest page version.
    protected String getKey(final String sessId, final String pageMapName, final int pageId, final int pageVersion) {
        int pageVer = (pageVersion == -1) ? 0 : pageVersion;
        if(pageVersion == -1) {
            String[] meta = getMeta(sessId, pageMapName, pageId);
            pageVer = Integer.valueOf(meta[0]);
        }

        return sessId + ":" + pageMapName + ":" + pageId + ":" + pageVer;
    }

    // If pageVersion -1 must return highest page version.
    // If ajaxVersion -1 must return highest version.
    public String getKey(final String sessId, final String pageMapName, final int pageId, final int pageVersion, final int ajaxVersion) {
        // Default it to 0 initially
        int ajaxVer = (ajaxVersion == -1) ? 0 : ajaxVersion;
        int pageVer = (pageVersion == -1) ? 0 : pageVersion;

        if(pageVersion == -1 || ajaxVersion == -1) {
            String[] meta = getMeta(sessId, pageMapName, pageId);
            if(pageVersion == -1) {
                pageVer = Integer.valueOf(meta[0]);
            }
            if(ajaxVersion == -1) {
                ajaxVer = Integer.valueOf(meta[1]);
            }
        }

        return sessId + ":" + pageMapName + ":" + pageId + ":" + pageVer + ":" + ajaxVer;
    }

    protected String storeKey(final String sessionId, final Page page) {
        return sessionId + ":" + page.getPageMapName() + ":" + page.getId() + ":" + page.getCurrentVersionNumber() + ":" + page.getAjaxVersionNumber();
    }

    protected String getBaseKey(String sessionId, Page page) {
        return sessionId + ":" + page.getPageMapName() + ":" + page.getId();
    }

    protected String getMetaKey(String sessionId, String pageMap, int id) {
        return getBaseKey(sessionId,pageMap,id)+"_meta";
    }

    protected String getMetaKey(String sessionId, Page page) {
        return getBaseKey(sessionId,page)+"_meta";
    }

    protected String getBaseKey(String sessionId, String pageMap, int id) {
        if(id == -1) {
            return sessionId + ":" + pageMap;
        } else {
            return sessionId + ":" + pageMap + ":" + id;
        }
    }
    public boolean containsPage(final String sessionId, final String pageMapName, final int pageId, final int pageVersion) {
        String key = getKey(sessionId, pageMapName, pageId, pageVersion, -1);
        if (logger.isDebugEnabled()) {
            logger.debug("CheckExists: " + key);
        }
        return cache.keyExists(key);
    }

    public void destroy() {
    }

    public  Page getPage(final String sessionId, final String pagemap, final int id, final int versionNumber, final int ajaxVersionNumber) {
        String key = getKey(sessionId, pagemap, id, versionNumber, ajaxVersionNumber);
        if (logger.isDebugEnabled()) {
            logger.debug("GetPage: " + key);
        }
        return (Page) cache.get(key);
    }

    public void pageAccessed(final String sessionId, final Page page) {
    }

    // If ID == -1 remove the entire pagemap; getBaseKey() takes care of this.
    public void removePage(final String sessionId, final String pagemap, final int id) {
        String key = getBaseKey(sessionId, pagemap, id);

        if (logger.isDebugEnabled()) {
            logger.debug("RemovePage: " + key);
        }

        cache.remove(getMetaKey(sessionId, pagemap, id));
        for (String k : cache.getKeys()) {
            if (k.startsWith(key)) {
                cache.remove(k);
            }
        }
    }

    protected String[] getMeta(final String sessionId, String pageMap, int pageId) {
        String metaKey = getMetaKey(sessionId,pageMap,pageId);
        Object ret = cache.get(metaKey);

        if (logger.isDebugEnabled()) {
            logger.debug("GetMeta: " + metaKey);
        }

        if(ret == null) {
            return new String[] {"0","0"};
        } else {
            return String.valueOf(ret).split(":");
        }
    }

    protected void storeMeta(final String sessionId, final Page page) {
        String metaKey = getMetaKey(sessionId, page);
        Object ret = cache.get(metaKey);

        if (logger.isDebugEnabled()) {
            logger.debug("StoreMeta: " + metaKey);
        }

        if(ret == null) {
            cache.put(metaKey,page.getCurrentVersionNumber()+":"+page.getAjaxVersionNumber());
        } else {
            String[] vals = String.valueOf(ret).split(":");
            int currPage = Integer.valueOf(vals[0]);
            int currAjax = Integer.valueOf(vals[1]);

            if(page.getCurrentVersionNumber() > currPage) {
                currPage = page.getCurrentVersionNumber();
            }
            if(page.getAjaxVersionNumber() > currAjax) {
                currAjax = page.getAjaxVersionNumber();
            }
            cache.put(metaKey,currPage+":"+currAjax);
        }
    }

    public void storePage(final String sessionId, final Page page) {
        String sKey = storeKey(sessionId, page);

        if (logger.isDebugEnabled()) {
            logger.debug("StorePage: " + sKey);
        }

        cache.put(sKey, page);
        storeMeta(sessionId,page);
    }

    public void unbind(final String sessionId) {
        if (logger.isDebugEnabled()) {
            logger.debug("Unbind: " + sessionId);
        }

        for (String key : cache.getKeys()) {
            if (key.startsWith(sessionId)) {
                cache.remove(key);
            }
        }
    }

}