aequasi
7/29/2013 - 9:57 PM

MemcachedStore class for the Symfony2 AppCache Class

MemcachedStore class for the Symfony2 AppCache Class

<?php

use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Aequasi\Bundle\MemcachedBundle\Cache\AntiStampedeMemcached as Memcached;

/**
 * {@inheritDoc}
 *
 * This specific Store caches data to a memcache instance
 *
 * @author Fabien Potencier <fabien@symfony.com>
 */
class MemcachedStore implements StoreInterface
{

	/**
	 * @var array
	 */
	protected $options;

	/**
	 * @var Memcached|\Memcached
	 */
	private $keyCache;

	/**
	 * @var string
	 */
	private static $prefix = 'SectionsReverseProxy';

	/**
	 * @var string
	 */
	private static $lockKey = 'Locks';

	/**
	 * Constructor.
	 *
	 * @param array $options Options for the memcache instance
	 * @param array $servers Array of server info
	 */
	public function __construct( array $options, array $servers )
	{

		$this->options  = $this->getDefaultOptions( $options );
		$this->keyCache = new Memcached( $options[ 'enabled' ], $options[ 'debug' ], $options[ 'persistentId' ] );
		$this->keyCache->addServers( $servers );
	}

	/**
	 * Cleanups storage.
	 *
	 * Unlocking everything
	 */
	public function cleanup()
	{

		$keys = $this->keyCache->get( $this->getLockKey() );
		if( empty( $keys ) ) {
			return true;
		}

		foreach( $keys as $key => $val ) {
			$this->keyCache->delete( $key );
		}

		$this->keyCache->set( $this->getLockKey(), [ ], null );
	}

	/**
	 * Locks the cache for a given Request.
	 *
	 * @param Request $request A Request instance
	 *
	 * @return Boolean true if the lock is acquired, false otherwise
	 */
	public function lock( Request $request )
	{

		$requestKey = $this->getCacheKey( $request );

		$lockData = $this->keyCache->get( $this->getLockKey() );
		if( in_array( $requestKey, $lockData ) ) {
			return false;
		}

		$lockData[ ] = $requestKey;
		$this->keyCache->set( $this->getLockKey(), $lockData, null );

		return true;
	}

	/**
	 * Releases the lock for the given Request.
	 *
	 * @param Request $request A Request instance
	 *
	 * @return Boolean False if the lock entry does not exist or cannot be unlocked, true otherwise
	 */
	public function unlock( Request $request )
	{

		$requestKey = $this->getCacheKey( $request );

		$lockData = $this->keyCache->get( $this->getLockKey() );
		if( $lockData === false ) {
			$lockData = [ ];
		}

		if( ( $key = array_search( $requestKey, $lockData ) ) !== false ) {
			unset( $lockData[ $key ] );
		}

		return $this->keyCache->set( $this->getLockKey(), $lockData, null );
	}

	/**
	 * @param Request $request
	 *
	 * @return bool
	 */
	public function isLocked( Request $request )
	{

		return in_array( $this->getCacheKey( $request ), $this->keyCache->get( $this->getLockKey() ) );
	}

	/**
	 * Locates a cached Response for the Request provided.
	 *
	 * @param Request $request A Request instance
	 *
	 * @return Response|null A Response instance, or null if no cache entry was found
	 */
	public function lookup( Request $request )
	{

		$key = $this->getCacheKey( $request );

		if( !$entries = $this->getMetadata( $key ) ) {
			return null;
		}

		// find a cached entry that matches the request.
		$match = null;
		foreach( $entries as $entry ) {
			if( $this->requestsMatch(
				isset( $entry[ 1 ][ 'vary' ][ 0 ] ) ? $entry[ 1 ][ 'vary' ][ 0 ] : '',
				$request->headers->all(),
				$entry[ 0 ]
			)
			) {
				$match = $entry;

				break;
			}
		}

		if( null === $match ) {
			return null;
		}

		list( $req, $headers ) = $match;
		$body = $this->keyCache->get( $headers[ 'x-content-digest' ][ 0 ] );
		if( !empty( $body ) ) {
			return $this->restoreResponse( $headers, $body );
		}

		// TODO the metaStore referenced an entity that doesn't exist in
		// the entityStore. We definitely want to return nil but we should
		// also purge the entry from the meta-store when this is detected.
		return null;
	}

	/**
	 * Writes a cache entry to the store for the given Request and Response.
	 *
	 * Existing entries are read and any that match the response are removed. This
	 * method calls write with the new list of cache entries.
	 *
	 * @param Request  $request  A Request instance
	 * @param Response $response A Response instance
	 *
	 * @return string The key under which the response is stored
	 *
	 * @throws \RuntimeException
	 */
	public function write( Request $request, Response $response )
	{

		$key       = $this->getCacheKey( $request );
		$storedEnv = $this->persistRequest( $request );

		// write the response body to the entity store if this is the original response
		if( !$response->headers->has( 'X-Content-Digest' ) ) {
			$digest = $this->generateContentDigest( $response );

			if( false === $this->save( $digest, $response->getContent() ) ) {
				throw new \RuntimeException( 'Unable to store the entity.' );
			}

			$response->headers->set( 'X-Content-Digest', $digest );

			if( !$response->headers->has( 'Transfer-Encoding' ) ) {
				$response->headers->set( 'Content-Length', strlen( $response->getContent() ) );
			}
		}

		// read existing cache entries, remove non-varying, and add this one to the list
		$entries = array();
		$vary    = $response->headers->get( 'vary' );
		foreach( $this->getMetadata( $key ) as $entry ) {
			if( !isset( $entry[ 1 ][ 'vary' ][ 0 ] ) ) {
				$entry[ 1 ][ 'vary' ] = array( '' );
			}

			if( $vary != $entry[ 1 ][ 'vary' ][ 0 ] || !$this->requestsMatch( $vary, $entry[ 0 ], $storedEnv ) ) {
				$entries[ ] = $entry;
			}
		}

		$headers = $this->persistResponse( $response );
		unset( $headers[ 'age' ] );

		array_unshift( $entries, array( $storedEnv, $headers ) );

		if( false === $this->save( $key, serialize( $entries ) ) ) {
			throw new \RuntimeException( 'Unable to store the metadata.' );
		}

		return $key;
	}

	/**
	 * Returns content digest for $response.
	 *
	 * @param Response $response
	 *
	 * @return string
	 */
	protected function generateContentDigest( Response $response )
	{

		return self::$prefix . '_' . 'en' . sha1( $response->getContent() );
	}

	/**
	 * Invalidates all cache entries that match the request.
	 *
	 * @param Request $request A Request instance
	 *
	 * @throws \RuntimeException
	 */
	public function invalidate( Request $request )
	{

		$modified = false;
		$key      = $this->getCacheKey( $request );

		$entries = array();
		foreach( $this->getMetadata( $key ) as $entry ) {
			$response = $this->restoreResponse( $entry[ 1 ] );

			if( $response->isFresh() ) {
				$response->expire();
				$modified   = true;
				$entries[ ] = array( $entry[ 0 ], $this->persistResponse( $response ) );
			} else {
				$entries[ ] = $entry;
			}
		}

		if( $modified ) {
			if( false === $this->save( $key, serialize( $entries ) ) ) {
				throw new \RuntimeException( 'Unable to store the metadata.' );
			}
		}
	}

	/**
	 * Determines whether two Request HTTP header sets are non-varying based on
	 * the vary response header value provided.
	 *
	 * @param string $vary A Response vary header
	 * @param array  $env1 A Request HTTP header array
	 * @param array  $env2 A Request HTTP header array
	 *
	 * @return Boolean true if the two environments match, false otherwise
	 */
	private function requestsMatch( $vary, $env1, $env2 )
	{

		if( empty( $vary ) ) {
			return true;
		}

		foreach( preg_split( '/[\s,]+/', $vary ) as $header ) {
			$key = strtr( strtolower( $header ), '_', '-' );
			$v1  = isset( $env1[ $key ] ) ? $env1[ $key ] : null;
			$v2  = isset( $env2[ $key ] ) ? $env2[ $key ] : null;
			if( $v1 !== $v2 ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Gets all data associated with the given key.
	 *
	 * Use this method only if you know what you are doing.
	 *
	 * @param string $key The store key
	 *
	 * @return array An array of data associated with the key
	 */
	private function getMetadata( $key )
	{

		if( false === $entries = $this->load( $key ) ) {
			return array();
		}

		return unserialize( $entries );
	}

	/**
	 * Purges data for the given URL.
	 *
	 * @param string $url A URL
	 *
	 * @return Boolean true if the URL exists and has been purged, false otherwise
	 */
	public function purge( $url )
	{

		return $this->keyCache->delete( $this->getCacheKey( Request::create( $url ) ) );
	}

	/**
	 * Loads data for the given key.
	 *
	 * @param string $key The store key
	 *
	 * @return string The data associated with the key
	 */
	private function load( $key )
	{

		return $this->keyCache->get( $key );
	}

	/**
	 * Save data for the given key.
	 *
	 * @param string $key  The store key
	 * @param string $data The data to store
	 *
	 * @return Boolean
	 */
	private function save( $key, $data )
	{

		$this->keyCache->set( $key, $data, null );
	}

	/**
	 * Returns a cache key for the given Request.
	 *
	 * @param Request $request A Request instance
	 *
	 * @return string A key for the given Request
	 */
	private function getCacheKey( Request $request )
	{

		$keyString = sprintf(
			"%s %s",
			$request->getMethod(),
			$request->getRequestUri()
		);

		$data = $this->keyCache->get( self::$prefix . '_' . $keyString );

		if( empty( $data ) ) {
			$data = self::$prefix . '_' . 'md' . sha1( $keyString );
			$this->keyCache->set( self::$prefix . '_' . $request, $data, null );
		}

		return $data;
	}

	/**
	 * Persists the Request HTTP headers.
	 *
	 * @param Request $request A Request instance
	 *
	 * @return array An array of HTTP headers
	 */
	private function persistRequest( Request $request )
	{

		return $request->headers->all();
	}

	/**
	 * Persists the Response HTTP headers.
	 *
	 * @param Response $response A Response instance
	 *
	 * @return array An array of HTTP headers
	 */
	private function persistResponse( Response $response )
	{

		$headers               = $response->headers->all();
		$headers[ 'X-Status' ] = array( $response->getStatusCode() );

		return $headers;
	}

	/**
	 * Restores a Response from the HTTP headers and body.
	 *
	 * @param array  $headers An array of HTTP headers for the Response
	 * @param string $body    The Response body
	 *
	 * @return Response
	 */
	private function restoreResponse( $headers, $body = null )
	{

		$status = $headers[ 'X-Status' ][ 0 ];
		unset( $headers[ 'X-Status' ] );

		if( null !== $body ) {
			$headers[ 'X-Body-Eval' ] = $body;
		}

		return new Response( $body, $status, $headers );
	}

	/**
	 * Gets the lock key with a prefix
	 */
	private function getLockKey()
	{

		return self::$prefix . '_' . self::$lockKey;
	}

	/**
	 * @param array $options
	 *
	 * @return array
	 */
	private function getDefaultOptions( array $options )
	{

		return array_merge(
			[
				'enabled'      => true,
				'debug'        => false,
				'persistentId' => null
			],
			$options
		);
	}
}