]> gitweb.fluxo.info Git - lorea/elgg.git/commitdiff
Fixes #4290: adds volatile metadata cache, unit tests, and pre-loading for fetched...
authorSteve Clay <steve@mrclay.org>
Fri, 1 Jun 2012 21:02:18 +0000 (17:02 -0400)
committerSteve Clay <steve@mrclay.org>
Tue, 4 Sep 2012 02:26:27 +0000 (22:26 -0400)
engine/classes/ElggEntity.php
engine/classes/ElggMetadata.php
engine/classes/ElggPlugin.php
engine/classes/ElggVolatileMetadataCache.php [new file with mode: 0644]
engine/lib/entities.php
engine/lib/metadata.php
engine/tests/api/metadata_cache.php [new file with mode: 0644]

index 77c2bbf4d76b0a1fa9d7d2d7cb2d5a20e4c3aeee..929abceb2ee9381574acb780170659c3b8e787dc 100644 (file)
@@ -248,7 +248,9 @@ abstract class ElggEntity extends ElggData implements
         * @return mixed The value, or NULL if not found.
         */
        public function getMetaData($name) {
-               if ((int) ($this->guid) == 0) {
+               $guid = $this->getGUID();
+
+               if (! $guid) {
                        if (isset($this->temp_metadata[$name])) {
                                // md is returned as an array only if more than 1 entry
                                if (count($this->temp_metadata[$name]) == 1) {
@@ -261,21 +263,38 @@ abstract class ElggEntity extends ElggData implements
                        }
                }
 
+               // upon first cache miss, just load/cache all the metadata and retry.
+               // if this works, the rest of this function may not be needed!
+               $cache = elgg_get_metadata_cache();
+               if ($cache->isKnown($guid, $name)) {
+                       return $cache->load($guid, $name);
+               } else {
+                       $cache->populateFromEntities(array($guid));
+                       // in case ignore_access was on, we have to check again...
+                       if ($cache->isKnown($guid, $name)) {
+                               return $cache->load($guid, $name);
+                       }
+               }
+
                $md = elgg_get_metadata(array(
-                       'guid' => $this->getGUID(),
+                       'guid' => $guid,
                        'metadata_name' => $name,
                        'limit' => 0,
                ));
 
+               $value = null;
+
                if ($md && !is_array($md)) {
-                       return $md->value;
+                       $value = $md->value;
                } elseif (count($md) == 1) {
-                       return $md[0]->value;
+                       $value = $md[0]->value;
                } else if ($md && is_array($md)) {
-                       return metadata_array_to_values($md);
+                       $value = metadata_array_to_values($md);
                }
 
-               return null;
+               $cache->save($guid, $name, $value);
+
+               return $value;
        }
 
        /**
@@ -1007,7 +1026,7 @@ abstract class ElggEntity extends ElggData implements
        /**
         * Returns the guid.
         *
-        * @return int GUID
+        * @return int|null GUID
         */
        public function getGUID() {
                return $this->get('guid');
@@ -1245,16 +1264,16 @@ abstract class ElggEntity extends ElggData implements
        /**
         * Save an entity.
         *
-        * @return bool/int
+        * @return bool|int
         * @throws IOException
         */
        public function save() {
-               $guid = (int) $this->guid;
+               $guid = $this->getGUID();
                if ($guid > 0) {
                        cache_entity($this);
 
                        return update_entity(
-                               $this->get('guid'),
+                               $guid,
                                $this->get('owner_guid'),
                                $this->get('access_id'),
                                $this->get('container_guid'),
@@ -1301,10 +1320,7 @@ abstract class ElggEntity extends ElggData implements
                        $this->attributes['subtype'] = get_subtype_id($this->attributes['type'],
                                $this->attributes['subtype']);
 
-                       // Cache object handle
-                       if ($this->attributes['guid']) {
-                               cache_entity($this);
-                       }
+                       cache_entity($this);
 
                        return $this->attributes['guid'];
                }
index 634a122e561e1616509458294c27b27755895f8e..7f45dc3ea902040d7ff4ea76c5378be0676fbc90 100644 (file)
@@ -26,8 +26,6 @@ class ElggMetadata extends ElggExtender {
         * Construct a metadata object
         *
         * @param mixed $id ID of metadata or a database row as stdClass object
-        *
-        * @return void
         */
        function __construct($id = null) {
                $this->initializeAttributes();
@@ -54,7 +52,7 @@ class ElggMetadata extends ElggExtender {
         *
         * @param int $user_guid The GUID of the user (defaults to currently logged in user)
         *
-        * @return true|false Depending on permissions
+        * @return bool Depending on permissions
         */
        function canEdit($user_guid = 0) {
                if ($entity = get_entity($this->get('entity_guid'))) {
@@ -64,9 +62,11 @@ class ElggMetadata extends ElggExtender {
        }
 
        /**
-        * Save matadata object
+        * Save metadata object
         *
-        * @return int the metadata object id
+        * @return int|bool the metadata object id or true if updated
+        *
+        * @throws IOException
         */
        function save() {
                if ($this->id > 0) {
@@ -89,7 +89,13 @@ class ElggMetadata extends ElggExtender {
         * @return bool
         */
        function delete() {
-               return elgg_delete_metastring_based_object_by_id($this->id, 'metadata');
+               $success = elgg_delete_metastring_based_object_by_id($this->id, 'metadata');
+               if ($success) {
+                       // we mark unknown here because this deletes only one value
+                       // under this name, and there may be others remaining.
+                       elgg_get_metadata_cache()->markUnknown($this->entity_guid, $this->name);
+               }
+               return $success;
        }
 
        /**
@@ -99,17 +105,27 @@ class ElggMetadata extends ElggExtender {
         * @since 1.8
         */
        function disable() {
-               return elgg_set_metastring_based_object_enabled_by_id($this->id, 'no', 'metadata');
+               $success = elgg_set_metastring_based_object_enabled_by_id($this->id, 'no', 'metadata');
+               if ($success) {
+                       // we mark unknown here because this disables only one value
+                       // under this name, and there may be others remaining.
+                       elgg_get_metadata_cache()->markUnknown($this->entity_guid, $this->name);
+               }
+               return $success;
        }
 
        /**
-        * Disable the metadata
+        * Enable the metadata
         *
         * @return bool
         * @since 1.8
         */
        function enable() {
-               return elgg_set_metastring_based_object_enabled_by_id($this->id, 'yes', 'metadata');
+               $success = elgg_set_metastring_based_object_enabled_by_id($this->id, 'yes', 'metadata');
+               if ($success) {
+                       elgg_get_metadata_cache()->markUnknown($this->entity_guid, $this->name);
+               }
+               return $success;
        }
 
        /**
index 8c9093834d827a5faa2df5db61ae75bbb841c41f..3e43c8e816aef962f5d4f11c45da3447588c1c74 100644 (file)
@@ -101,7 +101,6 @@ class ElggPlugin extends ElggObject {
                        $missing_attributes = array_diff_key($expected_attributes, $row);
                        if ($missing_attributes) {
                                $needs_loaded = true;
-                               $old_guid = $guid;
                                $guid = $row['guid'];
                        } else {
                                $this->attributes = $row;
@@ -132,10 +131,7 @@ class ElggPlugin extends ElggObject {
                // guid needs to be an int  http://trac.elgg.org/ticket/4111
                $this->attributes['guid'] = (int)$this->attributes['guid'];
 
-               // cache the entity
-               if ($this->attributes['guid']) {
-                       cache_entity($this);
-               }
+               cache_entity($this);
 
                return true;
        }
diff --git a/engine/classes/ElggVolatileMetadataCache.php b/engine/classes/ElggVolatileMetadataCache.php
new file mode 100644 (file)
index 0000000..24ae58d
--- /dev/null
@@ -0,0 +1,344 @@
+<?php
+/**
+ * ElggVolatileMetadataCache
+ * In memory cache of known metadata values stored by entity.
+ *
+ * @package    Elgg.Core
+ * @subpackage Cache
+ *
+ * @access private
+ */
+class ElggVolatileMetadataCache {
+
+       /**
+        * The cached values (or null for known to be empty). If the portion of the cache
+        * is synchronized, missing values are assumed to indicate that values do not
+        * exist in storage, otherwise, we don't know what's there.
+        *
+        * @var array
+        */
+       protected $values = array();
+
+       /**
+        * Does the cache know that it contains all names fetch-able from storage?
+        * The keys are entity GUIDs and either the value exists (true) or it's not set.
+        *
+        * @var array
+        */
+       protected $isSynchronized = array();
+
+       /**
+        * @var null|bool
+        */
+       protected $ignoreAccess = null;
+
+       /**
+        * @param int $entity_guid
+        *
+        * @param array $values
+        */
+       public function saveAll($entity_guid, array $values) {
+               if (!$this->getIgnoreAccess()) {
+                       $this->values[$entity_guid] = $values;
+                       $this->isSynchronized[$entity_guid] = true;
+               }
+       }
+
+       /**
+        * @param int $entity_guid
+        *
+        * @return array
+        */
+       public function loadAll($entity_guid) {
+               if (isset($this->values[$entity_guid])) {
+                       return $this->values[$entity_guid];
+               } else {
+                       return array();
+               }
+       }
+
+       /**
+        * Declare that there may be fetch-able metadata names in storage that this
+        * cache doesn't know about
+        *
+        * @param int $entity_guid
+        */
+       public function markOutOfSync($entity_guid) {
+               unset($this->isSynchronized[$entity_guid]);
+       }
+
+       /**
+        * @param $entity_guid
+        *
+        * @return bool
+        */
+       public function isSynchronized($entity_guid) {
+               return isset($this->isSynchronized[$entity_guid]);
+       }
+
+       /**
+        * @param int $entity_guid
+        *
+        * @param string $name
+        *
+        * @param array|int|string|null $value  null means it is known that there is no
+        *                                      fetch-able metadata under this name
+        * @param bool $allow_multiple
+        */
+       public function save($entity_guid, $name, $value, $allow_multiple = false) {
+               if ($this->getIgnoreAccess()) {
+                       // we don't know if what gets saves here will be available to user once
+                       // access control returns, hence it's best to forget :/
+                       $this->markUnknown($entity_guid, $name);
+               } else {
+                       if ($allow_multiple) {
+                               if ($this->isKnown($entity_guid, $name)) {
+                                       $existing = $this->load($entity_guid, $name);
+                                       if ($existing !== null) {
+                                               $existing = (array) $existing;
+                                               $existing[] = $value;
+                                               $value = $existing;
+                                       }
+                               } else {
+                                       // we don't know whether there are unknown values, so it's
+                                       // safest to leave that assumption
+                                       $this->markUnknown($entity_guid, $name);
+                                       return;
+                               }
+                       }
+                       $this->values[$entity_guid][$name] = $value;
+               }
+       }
+
+       /**
+        * Warning: You should always call isKnown() beforehand to verify that this
+        * function's return value should be trusted (otherwise a null return value
+        * is ambiguous).
+        *
+        * @param int $entity_guid
+        *
+        * @param string $name
+        *
+        * @return array|string|int|null null = value does not exist
+        */
+       public function load($entity_guid, $name) {
+               if (isset($this->values[$entity_guid]) && array_key_exists($name, $this->values[$entity_guid])) {
+                       return $this->values[$entity_guid][$name];
+               } else {
+                       return null;
+               }
+       }
+
+       /**
+        * Forget about this metadata entry. We don't want to try to guess what the
+        * next fetch from storage will return
+        *
+        * @param int $entity_guid
+        *
+        * @param string $name
+        */
+       public function markUnknown($entity_guid, $name) {
+               unset($this->values[$entity_guid][$name]);
+               $this->markOutOfSync($entity_guid);
+       }
+
+       /**
+        * If true, load() will return an accurate value for this name
+        *
+        * @param int $entity_guid
+        *
+        * @param string $name
+        *
+        * @return bool
+        */
+       public function isKnown($entity_guid, $name) {
+               if (isset($this->isSynchronized[$entity_guid])) {
+                       return true;
+               } else {
+                       return (isset($this->values[$entity_guid]) && array_key_exists($name, $this->values[$entity_guid]));
+               }
+
+       }
+
+       /**
+        * Declare that metadata under this name is known to be not fetch-able from storage
+        *
+        * @param int $entity_guid
+        *
+        * @param string $name
+        *
+        * @return array
+        */
+       public function markEmpty($entity_guid, $name) {
+               $this->values[$entity_guid][$name] = null;
+       }
+
+       /**
+        * Forget about all metadata for an entity
+        *
+        * @param int $entity_guid
+        */
+       public function clear($entity_guid) {
+               $this->values[$entity_guid] = array();
+               $this->markOutOfSync($entity_guid);
+       }
+
+       /**
+        * Clear entire cache and mark all entities as out of sync
+        */
+       public function flush() {
+               $this->values = array();
+               $this->isSynchronized = array();
+       }
+
+       /**
+        * Use this value instead of calling elgg_get_ignore_access(). By default that
+        * function will be called.
+        *
+        * This setting makes this component a little more loosely-coupled.
+        *
+        * @param bool $ignore
+        */
+       public function setIgnoreAccess($ignore) {
+               $this->ignoreAccess = (bool) $ignore;
+       }
+
+       /**
+        * Tell the cache to call elgg_get_ignore_access() to determing access status.
+        */
+       public function unsetIgnoreAccess() {
+               $this->ignoreAccess = null;
+       }
+
+       /**
+        * @return bool
+        */
+       protected function getIgnoreAccess() {
+               if (null === $this->ignoreAccess) {
+                       return elgg_get_ignore_access();
+               } else {
+                       return $this->ignoreAccess;
+               }
+       }
+
+       /**
+        * Invalidate based on options passed to the global *_metadata functions
+        *
+        * @param string $action  Action performed on metadata. "delete", "disable", or "enable"
+        *
+        * @param array $options  Options passed to elgg_(delete|disable|enable)_metadata
+        *
+        *   "guid" if given, invalidation will be limited to this entity
+        *
+        *   "metadata_name" if given, invalidation will be limited to metadata with this name
+        */
+       public function invalidateByOptions($action, array $options) {
+               // remove as little as possible, optimizing for common cases
+               if (empty($options['guid'])) {
+                       // safest to clear everything unless we want to make this even more complex :(
+                       $this->flush();
+               } else {
+                       if (empty($options['metadata_name'])) {
+                               // safest to clear the whole entity
+                               $this->clear($options['guid']);
+                       } else {
+                               switch ($action) {
+                                       case 'delete':
+                                               $this->markEmpty($options['guid'], $options['metadata_name']);
+                                               break;
+                                       default:
+                                               $this->markUnknown($options['guid'], $options['metadata_name']);
+                               }
+                       }
+               }
+       }
+
+       /**
+        * @param int|array $guids
+        */
+       public function populateFromEntities($guids) {
+               if (empty($guids)) {
+                       return;
+               }
+               if (!is_array($guids)) {
+                       $guids = array($guids);
+               }
+               $guids = array_unique($guids);
+
+               // could be useful at some point in future
+               //$guids = $this->filterMetadataHeavyEntities($guids);
+
+               $db_prefix = elgg_get_config('dbprefix');
+               $options = array(
+                       'guids' => $guids,
+                       'limit' => 0,
+                       'callback' => false,
+                       'joins' => array(
+                               "JOIN {$db_prefix}metastrings v ON n_table.value_id = v.id",
+                               "JOIN {$db_prefix}metastrings n ON n_table.name_id = n.id",
+                       ),
+                       'selects' => array('n.string AS name', 'v.string AS value'),
+                       'order_by' => 'n_table.entity_guid, n_table.time_created ASC',
+               );
+               $data = elgg_get_metadata($options);
+
+               // build up metadata for each entity, save when GUID changes (or data ends)
+               $last_guid = null;
+               $metadata = array();
+               $last_row_idx = count($data) - 1;
+               foreach ($data as $i => $row) {
+                       $name = $row->name;
+                       $value = ($row->value_type === 'text') ? $row->value : (int) $row->value;
+                       $guid = $row->entity_guid;
+                       if ($guid !== $last_guid) {
+                               if ($last_guid) {
+                                       $this->saveAll($last_guid, $metadata);
+                               }
+                               $metadata = array();
+                       }
+                       if (isset($metadata[$name])) {
+                               $metadata[$name] = (array) $metadata[$name];
+                               $metadata[$name][] = $value;
+                       } else {
+                               $metadata[$name] = $value;
+                       }
+                       if (($i == $last_row_idx)) {
+                               $this->saveAll($guid, $metadata);
+                       }
+                       $last_guid = $guid;
+               }
+       }
+
+       /**
+        * Filter out entities whose concatenated metadata values (INTs casted as string)
+        * exceed a threshold in characters. This could be used to avoid overpopulating the
+        * cache if RAM usage becomes an issue.
+        *
+        * @param array $guids GUIDs of entities to examine
+        *
+        * @param int $limit Limit in characters of all metadata (with ints casted to strings)
+        *
+        * @return array
+        */
+       public function filterMetadataHeavyEntities(array $guids, $limit = 1024000) {
+               $db_prefix = elgg_get_config('dbprefix');
+
+               $options = array(
+                       'guids' => $guids,
+                       'limit' => 0,
+                       'callback' => false,
+                       'joins' => "JOIN {$db_prefix}metastrings v ON n_table.value_id = v.id",
+                       'selects' => array('SUM(LENGTH(v.string)) AS bytes'),
+                       'order_by' => 'n_table.entity_guid, n_table.time_created ASC',
+                       'group_by' => 'n_table.entity_guid',
+               );
+               $data = elgg_get_metadata($options);
+               // don't cache if metadata for entity is over 10MB (or rolled INT)
+               foreach ($data as $row) {
+                       if ($row->bytes > $limit || $row->bytes < 0) {
+                               array_splice($guids, array_search($row->entity_guid, $guids), 1);
+                       }
+               }
+               return $guids;
+       }
+}
index 3896cd58f839f95548b58e26a4a6028ec9991155..e7f84b15b4ab7cdb3d86f06f3469db40849e77a7 100644 (file)
@@ -39,6 +39,8 @@ function invalidate_cache_for_entity($guid) {
        $guid = (int)$guid;
 
        unset($ENTITY_CACHE[$guid]);
+
+       elgg_get_metadata_cache()->clear($guid);
 }
 
 /**
@@ -606,12 +608,14 @@ function get_entity_as_row($guid) {
  *
  * @param stdClass $row The row of the entry in the entities table.
  *
- * @return object|false
+ * @return ElggEntity|false
  * @link http://docs.elgg.org/DataModel/Entities
  * @see get_entity_as_row()
  * @see add_subtype()
  * @see get_entity()
  * @access private
+ *
+ * @throws ClassException|InstallationException
  */
 function entity_row_to_elggstar($row) {
        if (!($row instanceof stdClass)) {
@@ -969,17 +973,25 @@ function elgg_get_entities(array $options = array()) {
 
                $dt = get_data($query, $options['callback']);
                if ($dt) {
-                       foreach ($dt as $entity) {
-                               // If a custom callback is provided, it could return something other than ElggEntity,
-                               // so we have to do an explicit check here.
-                               if ($entity instanceof ElggEntity) {
-                                       cache_entity($entity);
+                       // populate entity and metadata caches
+                       $guids = array();
+                       foreach ($dt as $item) {
+                               // A custom callback could result in items that aren't ElggEntity's, so check for them
+                               if ($item instanceof ElggEntity) {
+                                       cache_entity($item);
+                                       // plugins usually have only settings
+                                       if (!$item instanceof ElggPlugin) {
+                                               $guids[] = $item->guid;
+                                       }
                                }
                        }
                        // @todo Without this, recursive delete fails. See #4568
                        reset($dt);
-               }
 
+                       if ($guids) {
+                               elgg_get_metadata_cache()->populateFromEntities($guids);
+                       }
+               }
                return $dt;
        } else {
                $total = get_data_row($query);
@@ -1153,7 +1165,7 @@ function elgg_get_entity_type_subtype_where_sql($table, $types, $subtypes, $pair
  *                           best to provide in table.column format.
  * @param NULL|array $guids  Array of GUIDs.
  *
- * @return false|str
+ * @return false|string
  * @since 1.8.0
  * @access private
  */
@@ -1202,7 +1214,7 @@ function elgg_get_guid_based_where_sql($column, $guids) {
  * @param NULL|int $time_updated_upper Time updated upper limit
  * @param NULL|int $time_updated_lower Time updated lower limit
  *
- * @return FALSE|str FALSE on fail, string on success.
+ * @return FALSE|string FALSE on fail, string on success.
  * @since 1.7.0
  * @access private
  */
@@ -1304,7 +1316,7 @@ function elgg_list_entities(array $options = array(), $getter = 'elgg_get_entiti
  * @param string $subtype        The subtype of entity
  * @param int    $container_guid The container GUID that the entinties belong to
  * @param int    $site_guid      The site GUID
- * @param str    $order_by       Order_by SQL order by clause
+ * @param string $order_by       Order_by SQL order by clause
  *
  * @return array|false Either an array months as YYYYMM, or false on failure
  */
@@ -1649,7 +1661,7 @@ function delete_entity($guid, $recursive = true) {
  * @param string $returnvalue Return value from previous hook
  * @param array  $params      The parameters, passed 'guid' and 'varname'
  *
- * @return void
+ * @return ElggMetadata|null
  * @elgg_plugin_hook_handler volatile metadata
  * @todo investigate more.
  * @access private
@@ -1694,6 +1706,8 @@ function volatile_data_export_plugin_hook($hook, $entity_type, $returnvalue, $pa
  * @elgg_event_handler export all
  * @return mixed
  * @access private
+ *
+ * @throws InvalidParameterException|InvalidClassException
  */
 function export_entity_plugin_hook($hook, $entity_type, $returnvalue, $params) {
        // Sanity check values
@@ -1736,6 +1750,8 @@ function export_entity_plugin_hook($hook, $entity_type, $returnvalue, $params) {
  * @return ElggEntity the unsaved entity which should be populated by items.
  * @todo Remove this.
  * @access private
+ *
+ * @throws ClassException|InstallationException|ImportException
  */
 function oddentity_to_elggentity(ODDEntity $element) {
        $class = $element->getAttribute('class');
@@ -1807,6 +1823,8 @@ function oddentity_to_elggentity(ODDEntity $element) {
  * @elgg_plugin_hook_handler import all
  * @todo document
  * @access private
+ *
+ * @throws ImportException
  */
 function import_entity_plugin_hook($hook, $entity_type, $returnvalue, $params) {
        $element = $params['element'];
@@ -1853,8 +1871,6 @@ function import_entity_plugin_hook($hook, $entity_type, $returnvalue, $params) {
  * @link http://docs.elgg.org/Entities/AccessControl
  */
 function can_edit_entity($entity_guid, $user_guid = 0) {
-       global $CONFIG;
-
        $user_guid = (int)$user_guid;
        $user = get_entity($user_guid);
        if (!$user) {
@@ -1978,7 +1994,7 @@ function get_entity_url($entity_guid) {
  * @param string $entity_subtype The entity subtype
  * @param string $function_name  The function to register
  *
- * @return true|false Depending on success
+ * @return bool Depending on success
  * @see get_entity_url()
  * @see ElggEntity::getURL()
  * @since 1.8.0
@@ -2014,7 +2030,7 @@ function elgg_register_entity_url_handler($entity_type, $entity_subtype, $functi
  * @param string $type    The type of entity (object, site, user, group)
  * @param string $subtype The subtype to register (may be blank)
  *
- * @return true|false Depending on success
+ * @return bool Depending on success
  * @see get_registered_entity_types()
  * @link http://docs.elgg.org/Search
  * @link http://docs.elgg.org/Tutorials/Search
@@ -2051,7 +2067,7 @@ function elgg_register_entity_type($type, $subtype = null) {
  * @param string $type    The type of entity (object, site, user, group)
  * @param string $subtype The subtype to register (may be blank)
  *
- * @return true|false Depending on success
+ * @return bool Depending on success
  * @see elgg_register_entity_type()
  */
 function unregister_entity_type($type, $subtype) {
@@ -2118,7 +2134,7 @@ function get_registered_entity_types($type = null) {
  * @param string $type    The type of entity (object, site, user, group)
  * @param string $subtype The subtype (may be blank)
  *
- * @return true|false Depending on whether or not the type has been registered
+ * @return bool Depending on whether or not the type has been registered
  */
 function is_registered_entity_type($type, $subtype = null) {
        global $CONFIG;
@@ -2318,7 +2334,7 @@ function entities_gc() {
 /**
  * Runs unit tests for the entity objects.
  *
- * @param sting  $hook   unit_test
+ * @param string  $hook   unit_test
  * @param string $type   system
  * @param mixed  $value  Array of tests
  * @param mixed  $params Params
index 77fa30e412fc3eef23d9674243fbd19280d42e7b..f76c20f24a74e43b4bee4d8f3809ffea4f332d43 100644 (file)
@@ -12,7 +12,7 @@
  *
  * @param stdClass $row An object from the database
  *
- * @return stdClass or ElggMetadata
+ * @return stdClass|ElggMetadata
  * @access private
  */
 function row_to_elggmetadata($row) {
@@ -30,7 +30,7 @@ function row_to_elggmetadata($row) {
  *
  * @param int $id The id of the metadata object being retrieved.
  *
- * @return false|ElggMetadata
+ * @return ElggMetadata|false  FALSE if not found
  */
 function elgg_get_metadata_from_id($id) {
        return elgg_get_metastring_based_object_from_id($id, 'metadata');
@@ -64,7 +64,7 @@ function elgg_delete_metadata_by_id($id) {
  * @param int    $access_id      Default is ACCESS_PRIVATE
  * @param bool   $allow_multiple Allow multiple values for one key. Default is FALSE
  *
- * @return int/bool id of metadata or FALSE if failure
+ * @return int|false id of metadata or FALSE if failure
  */
 function create_metadata($entity_guid, $name, $value, $value_type = '', $owner_guid = 0,
        $access_id = ACCESS_PRIVATE, $allow_multiple = false) {
@@ -90,8 +90,6 @@ function create_metadata($entity_guid, $name, $value, $value_type = '', $owner_g
 
        $access_id = (int)$access_id;
 
-       $id = false;
-
        $query = "SELECT * from {$CONFIG->dbprefix}metadata"
                . " WHERE entity_guid = $entity_guid and name_id=" . add_metastring($name) . " limit 1";
 
@@ -106,34 +104,33 @@ function create_metadata($entity_guid, $name, $value, $value_type = '', $owner_g
        } else {
                // Support boolean types
                if (is_bool($value)) {
-                       if ($value) {
-                               $value = 1;
-                       } else {
-                               $value = 0;
-                       }
+                       $value = (int) $value;
                }
 
                // Add the metastrings
-               $value = add_metastring($value);
-               if (!$value) {
+               $value_id = add_metastring($value);
+               if (!$value_id) {
                        return false;
                }
 
-               $name = add_metastring($name);
-               if (!$name) {
+               $name_id = add_metastring($name);
+               if (!$name_id) {
                        return false;
                }
 
                // If ok then add it
                $query = "INSERT into {$CONFIG->dbprefix}metadata"
                        . " (entity_guid, name_id, value_id, value_type, owner_guid, time_created, access_id)"
-                       . " VALUES ($entity_guid, '$name','$value','$value_type', $owner_guid, $time, $access_id)";
+                       . " VALUES ($entity_guid, '$name_id','$value_id','$value_type', $owner_guid, $time, $access_id)";
 
                $id = insert_data($query);
 
                if ($id !== false) {
                        $obj = elgg_get_metadata_from_id($id);
                        if (elgg_trigger_event('create', 'metadata', $obj)) {
+
+                               elgg_get_metadata_cache()->save($entity_guid, $name, $value, $allow_multiple);
+
                                return $id;
                        } else {
                                elgg_delete_metadata_by_id($id);
@@ -175,6 +172,7 @@ function update_metadata($id, $name, $value, $value_type, $owner_guid, $access_i
        }
 
        if ($metabyname_memcache) {
+               // @todo fix memcache (name_id is not a property of ElggMetadata)
                $metabyname_memcache->delete("{$md->entity_guid}:{$md->name_id}");
        }
 
@@ -187,15 +185,9 @@ function update_metadata($id, $name, $value, $value_type, $owner_guid, $access_i
 
        $access_id = (int)$access_id;
 
-       $access = get_access_sql_suffix();
-
        // Support boolean types (as integers)
        if (is_bool($value)) {
-               if ($value) {
-                       $value = 1;
-               } else {
-                       $value = 0;
-               }
+               $value = (int) $value;
        }
 
        // Add the metastring
@@ -216,6 +208,9 @@ function update_metadata($id, $name, $value, $value_type, $owner_guid, $access_i
 
        $result = update_data($query);
        if ($result !== false) {
+
+               elgg_get_metadata_cache()->save($md->entity_guid, $name, $value);
+
                // @todo this event tells you the metadata has been updated, but does not
                // let you do anything about it. What is needed is a plugin hook before
                // the update that passes old and new values.
@@ -234,7 +229,7 @@ function update_metadata($id, $name, $value, $value_type, $owner_guid, $access_i
  * associative arrays and there is no guarantee on the ordering in the array.
  *
  * @param int    $entity_guid     The entity to attach the metadata to
- * @param string $name_and_values Associative array - a value can be a string, number, bool
+ * @param array  $name_and_values Associative array - a value can be a string, number, bool
  * @param string $value_type      'text', 'integer', or '' for automatic detection
  * @param int    $owner_guid      GUID of entity that owns the metadata
  * @param int    $access_id       Default is ACCESS_PRIVATE
@@ -308,6 +303,8 @@ function elgg_delete_metadata(array $options) {
                return false;
        }
 
+       elgg_get_metadata_cache()->invalidateByOptions('delete', $options);
+
        $options['metastring_type'] = 'metadata';
        return elgg_batch_metastring_based_objects($options, 'elgg_batch_delete_callback', false);
 }
@@ -328,6 +325,8 @@ function elgg_disable_metadata(array $options) {
                return false;
        }
 
+       elgg_get_metadata_cache()->invalidateByOptions('disable', $options);
+
        $options['metastring_type'] = 'metadata';
        return elgg_batch_metastring_based_objects($options, 'elgg_batch_disable_callback', false);
 }
@@ -348,6 +347,8 @@ function elgg_enable_metadata(array $options) {
                return false;
        }
 
+       elgg_get_metadata_cache()->invalidateByOptions('enable', $options);
+
        $options['metastring_type'] = 'metadata';
        return elgg_batch_metastring_based_objects($options, 'elgg_batch_enable_callback');
 }
@@ -449,16 +450,16 @@ function elgg_get_entities_from_metadata(array $options = array()) {
  * This function is reused for annotations because the tables are
  * exactly the same.
  *
- * @param string   $e_table           Entities table name
- * @param string   $n_table           Normalized metastrings table name (Where entities,
+ * @param string     $e_table           Entities table name
+ * @param string     $n_table           Normalized metastrings table name (Where entities,
  *                                    values, and names are joined. annotations / metadata)
- * @param arr|null $names             Array of names
- * @param arr|null $values            Array of values
- * @param arr|null $pairs             Array of names / values / operands
- * @param and|or   $pair_operator     Operator to use to join the where clauses for pairs
- * @param bool     $case_sensitive    Case sensitive metadata names?
- * @param arr|null $order_by_metadata Array of names / direction
- * @param arr|null $owner_guids       Array of owner GUIDs
+ * @param array|null $names             Array of names
+ * @param array|null $values            Array of values
+ * @param array|null $pairs             Array of names / values / operands
+ * @param string     $pair_operator     ("AND" or "OR") Operator to use to join the where clauses for pairs
+ * @param bool       $case_sensitive    Case sensitive metadata names?
+ * @param array|null $order_by_metadata Array of names / direction
+ * @param array|null $owner_guids       Array of owner GUIDs
  *
  * @return FALSE|array False on fail, array('joins', 'wheres')
  * @since 1.7.0
@@ -732,6 +733,8 @@ function elgg_list_entities_from_metadata($options) {
  *
  * @return array
  * @access private
+ *
+ * @throws InvalidParameterException
  */
 function export_metadata_plugin_hook($hook, $entity_type, $returnvalue, $params) {
        // Sanity check values
@@ -743,15 +746,13 @@ function export_metadata_plugin_hook($hook, $entity_type, $returnvalue, $params)
                throw new InvalidParameterException(elgg_echo('InvalidParameterException:NonArrayReturnValue'));
        }
 
-       $guid = (int)$params['guid'];
-       $name = $params['name'];
-
        $result = elgg_get_metadata(array(
-               'guid' => $guid,
-               'limit' => 0
+               'guid' => (int)$params['guid'],
+               'limit' => 0,
        ));
 
        if ($result) {
+               /* @var ElggMetadata[] $result */
                foreach ($result as $r) {
                        $returnvalue[] = $r->export();
                }
@@ -889,6 +890,50 @@ function elgg_register_metadata_url_handler($extender_name, $function) {
        return elgg_register_extender_url_handler('metadata', $extender_name, $function);
 }
 
+/**
+ * Get the global metadata cache instance
+ *
+ * @return ElggVolatileMetadataCache
+ *
+ * @access private
+ */
+function elgg_get_metadata_cache() {
+       global $CONFIG;
+       if (empty($CONFIG->local_metadata_cache)) {
+               $CONFIG->local_metadata_cache = new ElggVolatileMetadataCache();
+       }
+       return $CONFIG->local_metadata_cache;
+}
+
+/**
+ * Invalidate the metadata cache based on options passed to various *_metadata functions
+ *
+ * @param string $action  Action performed on metadata. "delete", "disable", or "enable"
+ *
+ * @param array $options  Options passed to elgg_(delete|disable|enable)_metadata
+ */
+function elgg_invalidate_metadata_cache($action, array $options) {
+       // remove as little as possible, optimizing for common cases
+       $cache = elgg_get_metadata_cache();
+       if (empty($options['guid'])) {
+               // safest to clear everything unless we want to make this even more complex :(
+               $cache->flush();
+       } else {
+               if (empty($options['metadata_name'])) {
+                       // safest to clear the whole entity
+                       $cache->clear($options['guid']);
+               } else {
+                       switch ($action) {
+                               case 'delete':
+                                       $cache->markEmpty($options['guid'], $options['metadata_name']);
+                                       break;
+                               default:
+                                       $cache->markUnknown($options['guid'], $options['metadata_name']);
+                       }
+               }
+       }
+}
+
 /** Register the hook */
 elgg_register_plugin_hook_handler("export", "all", "export_metadata_plugin_hook", 2);
 
@@ -912,5 +957,6 @@ elgg_register_plugin_hook_handler('unit_test', 'system', 'metadata_test');
 function metadata_test($hook, $type, $value, $params) {
        global $CONFIG;
        $value[] = $CONFIG->path . 'engine/tests/api/metadata.php';
+       $value[] = $CONFIG->path . 'engine/tests/api/metadata_cache.php';
        return $value;
-}
\ No newline at end of file
+}
diff --git a/engine/tests/api/metadata_cache.php b/engine/tests/api/metadata_cache.php
new file mode 100644 (file)
index 0000000..846116a
--- /dev/null
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Elgg Test metadata cache
+ *
+ * @package Elgg
+ * @subpackage Test
+ */
+class ElggCoreMetadataCacheTest extends ElggCoreUnitTest {
+
+       /**
+        * @var ElggVolatileMetadataCache
+        */
+       protected $cache;
+
+       /**
+        * @var ElggObject
+        */
+       protected $obj1;
+
+       /**
+        * @var int
+        */
+       protected $guid1;
+
+       /**
+        * @var ElggObject
+        */
+       protected $obj2;
+
+       /**
+        * @var int
+        */
+       protected $guid2;
+
+       protected $name = 'test';
+       protected $value = 'test';
+       protected $ignoreAccess;
+
+       /**
+        * Called before each test method.
+        */
+       public function setUp() {
+               $this->ignoreAccess = elgg_set_ignore_access(false);
+
+               $this->cache = elgg_get_metadata_cache();
+
+               $this->obj1 = new ElggObject();
+               $this->obj1->save();
+               $this->guid1 = $this->obj1->guid;
+
+               $this->obj2 = new ElggObject();
+               $this->obj2->save();
+               $this->guid2 = $this->obj2->guid;
+       }
+
+       /**
+        * Called after each test method.
+        */
+       public function tearDown() {
+               $this->obj1->delete();
+               $this->obj2->delete();
+
+               elgg_set_ignore_access($this->ignoreAccess);
+       }
+
+       public function testBasicApi() {
+               // test de-coupled instance
+               $cache = new ElggVolatileMetadataCache();
+               $cache->setIgnoreAccess(false);
+               $guid = 1;
+
+               $this->assertFalse($cache->isKnown($guid, $this->name));
+
+               $cache->markEmpty($guid, $this->name);
+               $this->assertTrue($cache->isKnown($guid, $this->name));
+               $this->assertNull($cache->load($guid, $this->name));
+
+               $cache->markUnknown($guid, $this->name);
+               $this->assertFalse($cache->isKnown($guid, $this->name));
+
+               $cache->save($guid, $this->name, $this->value);
+               $this->assertIdentical($cache->load($guid, $this->name), $this->value);
+
+               $cache->save($guid, $this->name, 1, true);
+               $this->assertIdentical($cache->load($guid, $this->name), array($this->value, 1));
+
+               $cache->clear($guid);
+               $this->assertFalse($cache->isKnown($guid, $this->name));
+       }
+
+       public function testReadsAreCached() {
+               // test that reads fill cache
+               $this->obj1->setMetaData($this->name, $this->value);
+               $this->cache->flush();
+
+               $this->obj1->getMetaData($this->name);
+               $this->assertIdentical($this->cache->load($this->guid1, $this->name), $this->value);
+       }
+
+       public function testWritesAreCached() {
+               // delete should mark cache as known to be empty
+               $this->obj1->deleteMetadata($this->name);
+               $this->assertTrue($this->cache->isKnown($this->guid1, $this->name));
+               $this->assertNull($this->cache->load($this->guid1, $this->name));
+
+               // without name, delete should invalidate the entire entity
+               $this->cache->save($this->guid1, $this->name, $this->value);
+               elgg_delete_metadata(array(
+                       'guid' => $this->guid1,
+               ));
+               $this->assertFalse($this->cache->isKnown($this->guid1, $this->name));
+
+               // test set
+               $this->obj1->setMetaData($this->name, $this->value);
+               $this->assertIdentical($this->cache->load($this->guid1, $this->name), $this->value);
+
+               // test set multiple
+               $this->obj1->setMetaData($this->name, 1, 'integer', true);
+               $this->assertIdentical($this->cache->load($this->guid1, $this->name), array($this->value, 1));
+
+               // writes when access is ignore should invalidate
+               $tmp_ignore = elgg_set_ignore_access(true);
+               $this->obj1->setMetaData($this->name, $this->value);
+               $this->assertFalse($this->cache->isKnown($this->guid1, $this->name));
+               elgg_set_ignore_access($tmp_ignore);
+       }
+
+       public function testDisableAndEnable() {
+               // both should mark cache unknown
+               $this->obj1->setMetaData($this->name, $this->value);
+               $this->obj1->disableMetadata($this->name);
+               $this->assertFalse($this->cache->isKnown($this->guid1, $this->name));
+
+               $this->cache->save($this->guid1, $this->name, $this->value);
+               $this->obj1->enableMetadata($this->name);
+               $this->assertFalse($this->cache->isKnown($this->guid1, $this->name));
+       }
+
+       public function testPopulateFromEntities() {
+               // test populating cache from set of entities
+               $this->obj1->setMetaData($this->name, $this->value);
+               $this->obj1->setMetaData($this->name, 4, 'integer', true);
+               $this->obj1->setMetaData("{$this->name}-2", "{$this->value}-2");
+               $this->obj2->setMetaData($this->name, $this->value);
+
+               $this->cache->flush();
+               $this->cache->populateFromEntities(array($this->guid1, $this->guid2));
+
+               $expected = array();
+               $expected[$this->name][] = $this->value;
+               $expected[$this->name][] = 4;
+               $expected["{$this->name}-2"] = "{$this->value}-2";
+               $this->assertIdentical($this->cache->loadAll($this->guid1), $expected);
+
+               $expected = array();
+               $expected[$this->name] = $this->value;
+               $this->assertIdentical($this->cache->loadAll($this->guid2), $expected);
+       }
+
+       public function testFilterHeavyEntities() {
+               $big_str = str_repeat('-', 5000);
+               $this->obj2->setMetaData($this->name, array($big_str, $big_str));
+
+               $guids = array($this->guid1, $this->guid2);
+               $expected = array($this->guid1);
+               $actual = $this->cache->filterMetadataHeavyEntities($guids, 6000);
+               $this->assertIdentical($actual, $expected);
+       }
+}