From 61274b8cec92ee86dec24c99fd6ef180c1681ab2 Mon Sep 17 00:00:00 2001 From: Steve Clay Date: Fri, 1 Jun 2012 17:02:18 -0400 Subject: Fixes #4290: adds volatile metadata cache, unit tests, and pre-loading for fetched entities --- engine/classes/ElggEntity.php | 44 ++-- engine/classes/ElggMetadata.php | 34 ++- engine/classes/ElggPlugin.php | 6 +- engine/classes/ElggVolatileMetadataCache.php | 344 +++++++++++++++++++++++++++ engine/lib/entities.php | 52 ++-- engine/lib/metadata.php | 122 +++++++--- engine/tests/api/metadata_cache.php | 169 +++++++++++++ 7 files changed, 687 insertions(+), 84 deletions(-) create mode 100644 engine/classes/ElggVolatileMetadataCache.php create mode 100644 engine/tests/api/metadata_cache.php (limited to 'engine') diff --git a/engine/classes/ElggEntity.php b/engine/classes/ElggEntity.php index 77c2bbf4d..929abceb2 100644 --- a/engine/classes/ElggEntity.php +++ b/engine/classes/ElggEntity.php @@ -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']; } diff --git a/engine/classes/ElggMetadata.php b/engine/classes/ElggMetadata.php index 634a122e5..7f45dc3ea 100644 --- a/engine/classes/ElggMetadata.php +++ b/engine/classes/ElggMetadata.php @@ -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; } /** diff --git a/engine/classes/ElggPlugin.php b/engine/classes/ElggPlugin.php index 8c9093834..3e43c8e81 100644 --- a/engine/classes/ElggPlugin.php +++ b/engine/classes/ElggPlugin.php @@ -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 index 000000000..24ae58d42 --- /dev/null +++ b/engine/classes/ElggVolatileMetadataCache.php @@ -0,0 +1,344 @@ +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; + } +} diff --git a/engine/lib/entities.php b/engine/lib/entities.php index 3896cd58f..e7f84b15b 100644 --- a/engine/lib/entities.php +++ b/engine/lib/entities.php @@ -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 diff --git a/engine/lib/metadata.php b/engine/lib/metadata.php index 77fa30e41..f76c20f24 100644 --- a/engine/lib/metadata.php +++ b/engine/lib/metadata.php @@ -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 index 000000000..846116a7b --- /dev/null +++ b/engine/tests/api/metadata_cache.php @@ -0,0 +1,169 @@ +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); + } +} -- cgit v1.2.3