diff --git a/composer.json b/composer.json index 643c3f7..ed0e1fc 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,12 @@ "cbf": "phpcbf --standard=PSR2 -n src", "api": "PATH=$HOME/bin:$PATH sami.phar --ansi update sami-config.php", "sami-install": "mkdir -p $HOME/bin && curl --output $HOME/bin/sami.phar http://get.sensiolabs.org/sami.phar && chmod +x $HOME/bin/sami.phar", - "test": "phpunit" + "test": "phpunit", + "test-state": "phpunit --group state", + "test-l1": "phpunit --group L1", + "test-l2": "phpunit --group L2", + "test-integration": "phpunit --group integration", + "test-failing": "phpunit --group failing" }, "extra": { "branch-alias": { diff --git a/src/APCuL1.php b/src/APCuL1.php index 45afe7c..a559862 100644 --- a/src/APCuL1.php +++ b/src/APCuL1.php @@ -121,7 +121,6 @@ public function delete($event_id, Address $address) $localKey = $this->getLocalKey($address); if ($address->isEntireCache() || $address->isEntireBin()) { - $localKey = $this->getLocalKey($address); $pattern = '/^' . preg_quote($localKey) . '.*/'; $matching = $this->getIterator($pattern, APC_ITER_KEY); assert(!is_null($matching), 'Iterator instantiation failed.'); diff --git a/src/Address.php b/src/Address.php index c57d16c..a271229 100644 --- a/src/Address.php +++ b/src/Address.php @@ -70,12 +70,16 @@ public function isEntireCache() */ public function isMatch(Address $address) { - if (!is_null($address->getBin()) && !is_null($this->bin) && $address->getBin() !== $this->bin) { + $bin = $address->getBin(); + if (!(null === $bin || null === $this->bin || $bin === $this->bin)) { return false; } - if (!is_null($address->getKey()) && !is_null($this->key) && $address->getKey() !== $this->key) { + + $key = $address->getKey(); + if (!(null === $key || null === $this->key || $key === $this->key)) { return false; } + return true; } diff --git a/src/DatabaseL2.php b/src/DatabaseL2.php index d28b8c8..8030a63 100644 --- a/src/DatabaseL2.php +++ b/src/DatabaseL2.php @@ -4,27 +4,49 @@ class DatabaseL2 extends L2 { + /** @var int */ protected $hits; + + /** @var int */ protected $misses; + + /** @var \PDO Database handle object. */ protected $dbh; + + /** @var bool */ protected $log_locally; + + /** @var array List of errors that are logged. */ protected $errors; + + /** @var string */ protected $table_prefix; - protected $address_deletion_patterns; - protected $event_id_low_water; + + /** @var array Aggregated list of addresses to be deleted in bulk. */ + protected $address_delete_queue; + + protected $tagsTable; + protected $eventsTable; public function __construct($dbh, $table_prefix = '', $log_locally = false) { $this->hits = 0; $this->misses = 0; + $this->errors = []; + $this->address_delete_queue = []; + $this->dbh = $dbh; $this->log_locally = $log_locally; - $this->errors = array(); $this->table_prefix = $table_prefix; - $this->address_deletion_patterns = []; - $this->event_id_low_water = null; + + $this->tagsTable = $this->prefixTable('lcache_tags'); + $this->eventsTable = $this->prefixTable('lcache_events'); } + private function now() + { + return $_SERVER['REQUEST_TIME']; + } protected function prefixTable($base_name) { @@ -34,20 +56,20 @@ protected function prefixTable($base_name) public function pruneReplacedEvents() { // No deletions, nothing to do. - if (empty($this->address_deletion_patterns)) { + if (empty($this->address_delete_queue)) { return true; } - - // De-dupe the deletion patterns. // @TODO: Have bin deletions replace key deletions? - $deletions = array_values(array_unique($this->address_deletion_patterns)); - - $filler = implode(',', array_fill(0, count($deletions), '?')); try { - $sth = $this->dbh->prepare('DELETE FROM ' . $this->prefixTable('lcache_events') .' WHERE "event_id" < ? AND "address" IN ('. $filler .')'); - $sth->bindValue(1, $this->event_id_low_water, \PDO::PARAM_INT); - foreach ($deletions as $i => $address) { - $sth->bindValue($i + 2, $address, \PDO::PARAM_STR); + $conditions = array_fill(0, count($this->address_delete_queue), '("event_id" < ? and "address" = ?)'); + $sql = "DELETE FROM {$this->eventsTable}" + . " WHERE " . implode(' OR ', $conditions); + $sth = $this->dbh->prepare($sql); + foreach (array_keys($this->address_delete_queue) as $i => $address) { + $offset = $i << 1; + $event_id = $this->address_delete_queue[$address]; + $sth->bindValue($offset + 1, $event_id, \PDO::PARAM_INT); + $sth->bindValue($offset + 2, $address, \PDO::PARAM_STR); } $sth->execute(); } catch (\PDOException $e) { @@ -56,7 +78,7 @@ public function pruneReplacedEvents() } // Clear the queue. - $this->address_deletion_patterns = []; + $this->address_delete_queue = []; return true; } @@ -68,8 +90,11 @@ public function __destruct() public function countGarbage() { try { - $sth = $this->dbh->prepare('SELECT COUNT(*) garbage FROM ' . $this->prefixTable('lcache_events') . ' WHERE "expiration" < :now'); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); + $sql = 'SELECT COUNT(*) garbage' + . ' FROM ' . $this->eventsTable + . ' WHERE "expiration" < :now'; + $sth = $this->dbh->prepare($sql); + $sth->bindValue(':now', $this->now(), \PDO::PARAM_INT); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to count garbage', $e); @@ -82,19 +107,22 @@ public function countGarbage() public function collectGarbage($item_limit = null) { - $sql = 'DELETE FROM ' . $this->prefixTable('lcache_events') . ' WHERE "expiration" < :now'; + $sql = 'DELETE FROM ' . $this->eventsTable + . ' WHERE "expiration" < :now'; // This is not supported by standard SQLite. // @codeCoverageIgnoreStart - if (!is_null($item_limit)) { + $addLimit = !is_null($item_limit) + && $this->dbh->getAttribute(\PDO::ATTR_DRIVER_NAME) !== 'sqlite'; + if ($addLimit) { $sql .= ' ORDER BY "event_id" LIMIT :item_limit'; } // @codeCoverageIgnoreEnd try { $sth = $this->dbh->prepare($sql); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); + $sth->bindValue(':now', $this->now(), \PDO::PARAM_INT); // This is not supported by standard SQLite. // @codeCoverageIgnoreStart - if (!is_null($item_limit)) { + if ($addLimit) { $sth->bindValue(':item_limit', $item_limit, \PDO::PARAM_INT); } // @codeCoverageIgnoreEnd @@ -103,21 +131,24 @@ public function collectGarbage($item_limit = null) } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to collect garbage', $e); } - return false; + return 0; } - protected function queueDeletion(Address $address) + protected function queueDeletion($eventId, Address $address) { assert(!$address->isEntireBin()); - $pattern = $address->serialize(); - $this->address_deletion_patterns[] = $pattern; + // Key by the address, so we will have the last write event ID for a + // given address in the end of the request. + $this->address_delete_queue[$address->serialize()] = $eventId; } protected function logSchemaIssueOrRethrow($description, $pdo_exception) { - $log_only = array(/* General error */ 'HY000', - /* Unknown column */ '42S22', - /* Base table for view not found */ '42S02'); + $log_only = [ + 'HY000' /* General error */, + '42S22' /* Unknown column */, + '42S02' /* Base table for view not found */, + ]; if (in_array($pdo_exception->getCode(), $log_only, true)) { $text = 'LCache Database: ' . $description . ' : ' . $pdo_exception->getMessage(); @@ -147,28 +178,30 @@ public function getErrors() return $this->errors; } - // Returns an LCache\Entry + /** + * {inheritDock} + */ public function getEntry(Address $address) { try { - $sth = $this->dbh->prepare('SELECT "event_id", "pool", "address", "value", "created", "expiration" FROM ' . $this->prefixTable('lcache_events') .' WHERE "address" = :address AND ("expiration" >= :now OR "expiration" IS NULL) ORDER BY "event_id" DESC LIMIT 1'); + $sql = 'SELECT "event_id", "pool", "address", "value", "created", "expiration" ' + . ' FROM ' . $this->eventsTable + . ' WHERE "address" = :address ' + . ' AND ("expiration" >= :now OR "expiration" IS NULL) ' + . ' ORDER BY "event_id" DESC ' + . ' LIMIT 1'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); + $sth->bindValue(':now', $this->now(), \PDO::PARAM_INT); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to search database for cache item', $e); return null; } - //$last_matching_entry = $sth->fetchObject('LCacheEntry'); $last_matching_entry = $sth->fetchObject(); - if (false === $last_matching_entry) { - $this->misses++; - return null; - } - - // If last event was a deletion, miss. - if (is_null($last_matching_entry->value)) { + // No entry or the last one was a deletion - miss. + if (false === $last_matching_entry || is_null($last_matching_entry->value)) { $this->misses++; return null; } @@ -180,15 +213,26 @@ public function getEntry(Address $address) throw new UnserializationException($address, $last_matching_entry->value); } - $last_matching_entry->value = $unserialized_value; + // Prepare correct result object. + $entry = new \LCache\Entry( + $last_matching_entry->event_id, + $last_matching_entry->pool, + clone $address, + $unserialized_value, + $last_matching_entry->created + ); + $this->hits++; - return $last_matching_entry; + return $entry; } // Returns the event entry. Currently used only for testing. public function getEvent($event_id) { - $sth = $this->dbh->prepare('SELECT * FROM ' . $this->prefixTable('lcache_events') .' WHERE event_id = :event_id'); + $sql = 'SELECT *' + . ' FROM ' . $this->eventsTable + . ' WHERE event_id = :event_id'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':event_id', $event_id, \PDO::PARAM_INT); $sth->execute(); $event = $sth->fetchObject(); @@ -202,16 +246,29 @@ public function getEvent($event_id) public function exists(Address $address) { try { - $sth = $this->dbh->prepare('SELECT "event_id", ("value" IS NOT NULL) AS value_not_null, "value" FROM ' . $this->prefixTable('lcache_events') .' WHERE "address" = :address AND ("expiration" >= :now OR "expiration" IS NULL) ORDER BY "event_id" DESC LIMIT 1'); + $sql = 'SELECT ("value" IS NOT NULL) AS value_not_null ' + . ' FROM ' . $this->eventsTable + . ' WHERE "address" = :address' + . ' AND ("expiration" >= :now OR "expiration" IS NULL)' + . ' ORDER BY "event_id" DESC' + . ' LIMIT 1'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); + $sth->bindValue(':now', $this->now(), \PDO::PARAM_INT); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to search database for cache item existence', $e); return null; } $result = $sth->fetchObject(); - return ($result !== false && $result->value_not_null); + + $exists = ($result !== false && $result->value_not_null); + + // To comply wiht the LX interface that expects to use LX::get for the + // implementation, here we need to handle the hit/miss manually. + $this->{($exists ? 'hits' : 'misses')}++; + + return $exists; } /** @@ -220,17 +277,19 @@ public function exists(Address $address) public function debugDumpState() { echo PHP_EOL . PHP_EOL . 'Events:' . PHP_EOL; - $sth = $this->dbh->prepare('SELECT * FROM ' . $this->prefixTable('lcache_events') . ' ORDER BY "event_id"'); + $sth = $this->dbh->prepare('SELECT * FROM ' . $this->eventsTable . ' ORDER BY "event_id"'); $sth->execute(); while ($event = $sth->fetchObject()) { print_r($event); } + unset($sth); echo PHP_EOL; + echo 'Tags:' . PHP_EOL; - $sth = $this->dbh->prepare('SELECT * FROM ' . $this->prefixTable('lcache_tags') . ' ORDER BY "tag"'); - $sth->execute(); + $sth2 = $this->dbh->prepare('SELECT * FROM ' . $this->tagsTable . ' ORDER BY "tag"'); + $sth2->execute(); $tags_found = false; - while ($event = $sth->fetchObject()) { + while ($event = $sth2->fetchObject()) { print_r($event); $tags_found = true; } @@ -240,20 +299,33 @@ public function debugDumpState() echo PHP_EOL; } + /** + * @todo + * Should we consider transactions here? We are doing 3 queries: 1. Add an + * event, 2. Delete (if deemed so) and 3. Add tags (if any). All of that + * should behave as a single operation in DB. If DB driver is not + * supporting that - it should be emulated. + */ public function set($pool, Address $address, $value = null, $expiration = null, array $tags = [], $value_is_serialized = false) { // Support pre-serialized values for testing purposes. - if (!$value_is_serialized) { - $value = is_null($value) ? null : serialize($value); + if (!$value_is_serialized && !is_null($value)) { + $value = serialize($value); } + // Add the event to storage. try { - $sth = $this->dbh->prepare('INSERT INTO ' . $this->prefixTable('lcache_events') . ' ("pool", "address", "value", "created", "expiration") VALUES (:pool, :address, :value, :now, :expiration)'); + $sql = 'INSERT INTO ' . $this->eventsTable + . ' ("pool", "address", "value", "created", "expiration")' + . ' VALUES' + . ' (:pool, :address, :value, :now, :expiration)'; + + $sth = $this->dbh->prepare($sql); $sth->bindValue(':pool', $pool, \PDO::PARAM_STR); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':value', $value, \PDO::PARAM_LOB); $sth->bindValue(':expiration', $expiration, \PDO::PARAM_INT); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); + $sth->bindValue(':now', $this->now(), \PDO::PARAM_INT); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to store cache event', $e); @@ -264,25 +336,37 @@ public function set($pool, Address $address, $value = null, $expiration = null, // Handle bin and larger deletions immediately. Queue individual key // deletions for shutdown. if ($address->isEntireBin() || $address->isEntireCache()) { + $sql = 'DELETE FROM ' . $this->eventsTable + . ' WHERE "event_id" < :new_event_id' + . ' AND "address" LIKE :pattern'; $pattern = $address->serialize() . '%'; - $sth = $this->dbh->prepare('DELETE FROM ' . $this->prefixTable('lcache_events') .' WHERE "event_id" < :new_event_id AND "address" LIKE :pattern'); + $sth = $this->dbh->prepare($sql); $sth->bindValue(':new_event_id', $event_id, \PDO::PARAM_INT); $sth->bindValue(':pattern', $pattern, \PDO::PARAM_STR); $sth->execute(); } else { - if (is_null($this->event_id_low_water)) { - $this->event_id_low_water = $event_id; - } - $this->queueDeletion($address); + $this->queueDeletion($event_id, $address); } // Store any new cache tags. - // @TODO: Turn into one query. - foreach ($tags as $tag) { + if (!empty($tags)) { try { - $sth = $this->dbh->prepare('INSERT INTO ' . $this->prefixTable('lcache_tags') . ' ("tag", "event_id") VALUES (:tag, :new_event_id)'); - $sth->bindValue(':tag', $tag, \PDO::PARAM_STR); - $sth->bindValue(':new_event_id', $event_id, \PDO::PARAM_INT); + // Unify tags to avoid duplicate keys. + $tags = array_keys(array_flip($tags)); + + // TODO: Consider splitting to multiple multi-row queries. + // This might be needed when inserting MANY tags for a key. + // If so, have a configurable constant to do the splitting on. + $sql = 'INSERT INTO ' . $this->tagsTable + . ' ("tag", "event_id")' + . ' VALUES ' + . implode(',', array_fill(0, count($tags), '(?,?)')); + $sth = $this->dbh->prepare($sql); + foreach ($tags as $index => $tag_name) { + $offset = $index << 1; + $sth->bindValue($offset + 1, $tag_name, \PDO::PARAM_STR); + $sth->bindValue($offset + 2, $event_id, \PDO::PARAM_INT); + } $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to associate cache tags', $e); @@ -293,58 +377,66 @@ public function set($pool, Address $address, $value = null, $expiration = null, return $event_id; } - public function delete($pool, Address $address) - { - $event_id = $this->set($pool, $address); - return $event_id; - } - - public function getAddressesForTag($tag) + /** + * Initializes a generator for iterating over tag addresses one by one. + * + * @param string $tag + * Tag to search the addresses for. + * + * @return \Generator|null + * When a successfully execuded query is done an activated generator + * instance is returned. Otherwise NULL. + */ + private function getAddressesForTagGenerator($tag) { try { // @TODO: Convert this to using a subquery to only match with the latest event_id. - $sth = $this->dbh->prepare('SELECT DISTINCT "address" FROM ' . $this->prefixTable('lcache_events') . ' e INNER JOIN ' . $this->prefixTable('lcache_tags') . ' t ON t.event_id = e.event_id WHERE "tag" = :tag'); + // @TODO: Move the where condition to a join one to speed-up the query (benchmark with big DB). + $sql = 'SELECT DISTINCT "address"' + . ' FROM ' . $this->eventsTable . ' e' + . ' INNER JOIN ' . $this->tagsTable . ' t ON t.event_id = e.event_id' + . ' WHERE "tag" = :tag'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':tag', $tag, \PDO::PARAM_STR); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to find cache items associated with tag', $e); return null; } - $addresses = []; - while ($tag_entry = $sth->fetchObject()) { - $address = new Address(); - $address->unserialize($tag_entry->address); - $addresses[] = $address; + + return call_user_func(function () use ($sth) { + while ($tag_entry = $sth->fetchObject()) { + $address = new Address(); + $address->unserialize($tag_entry->address); + yield $address; + } + }); + } + + public function getAddressesForTag($tag) + { + if (($generator = $this->getAddressesForTagGenerator($tag)) === null) { + return null; } - return $addresses; + return iterator_to_array($generator); } public function deleteTag(L1 $l1, $tag) { - // Find the matching keys and create tombstones for them. - try { - $sth = $this->dbh->prepare('SELECT DISTINCT "address" FROM ' . $this->prefixTable('lcache_events') . ' e INNER JOIN ' . $this->prefixTable('lcache_tags') . ' t ON t.event_id = e.event_id WHERE "tag" = :tag'); - $sth->bindValue(':tag', $tag, \PDO::PARAM_STR); - $sth->execute(); - } catch (\PDOException $e) { - $this->logSchemaIssueOrRethrow('Failed to find cache items associated with tag', $e); + if (($addressGenerator = $this->getAddressesForTagGenerator($tag)) === null) { return null; } $last_applied_event_id = null; - while ($tag_entry = $sth->fetchObject()) { - $address = new Address(); - $address->unserialize($tag_entry->address); + foreach ($addressGenerator as $address) { $last_applied_event_id = $this->delete($l1->getPool(), $address); $l1->delete($last_applied_event_id, $address); } - - // Delete the tag, which has now been invalidated. - // @TODO: Move to a transaction, collect the list of deleted keys, - // or delete individual tag/key pairs in the loop above. - //$sth = $this->dbh->prepare('DELETE FROM ' . $this->prefixTable('lcache_tags') . ' WHERE "tag" = :tag'); - //$sth->bindValue(':tag', $tag, PDO::PARAM_STR); - //$sth->execute(); + // We have the possibility to delete many addreses one by one. Any + // consecuitive tag delete will atempt to delete them again, if not + // prunned explicitly here. By deleting the stale / old events, we use + // the DB's ON DELETE CASCADE to clear the relaed tags also. + $this->pruneReplacedEvents(); return $last_applied_event_id; } @@ -357,34 +449,44 @@ public function applyEvents(L1 $l1) // to the current high-water mark. if (is_null($last_applied_event_id)) { try { - $sth = $this->dbh->prepare('SELECT "event_id" FROM ' . $this->prefixTable('lcache_events') . ' ORDER BY "event_id" DESC LIMIT 1'); + $sql = 'SELECT "event_id"' + . ' FROM ' . $this->eventsTable + . ' ORDER BY "event_id" DESC' + . ' LIMIT 1'; + $sth = $this->dbh->prepare($sql); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to initialize local event application status', $e); return null; } $last_event = $sth->fetchObject(); - if (false === $last_event) { - $l1->setLastAppliedEventID(0); - } else { - $l1->setLastAppliedEventID($last_event->event_id); - } + $value = false === $last_event ? 0 : (int) $last_event->event_id; + $l1->setLastAppliedEventID($value); return null; } - $applied = 0; try { - $sth = $this->dbh->prepare('SELECT "event_id", "pool", "address", "value", "created", "expiration" FROM ' . $this->prefixTable('lcache_events') . ' WHERE "event_id" > :last_applied_event_id AND "pool" <> :exclude_pool ORDER BY event_id'); + $sql = 'SELECT "event_id", "pool", "address", "value", "created", "expiration"' + . ' FROM ' . $this->eventsTable + . ' WHERE "event_id" > :last_applied_event_id' + . ' ORDER BY event_id'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':last_applied_event_id', $last_applied_event_id, \PDO::PARAM_INT); - $sth->bindValue(':exclude_pool', $l1->getPool(), \PDO::PARAM_STR); $sth->execute(); } catch (\PDOException $e) { $this->logSchemaIssueOrRethrow('Failed to fetch events', $e); return null; } - //while ($event = $sth->fetchObject('LCacheEntry')) { + $applied = 0; while ($event = $sth->fetchObject()) { + $last_applied_event_id = $event->event_id; + + // Were created by the local L1. + if ($event->pool === $l1->getPool()) { + continue; + } + $address = new Address(); $address->unserialize($event->address); if (is_null($event->value)) { @@ -395,13 +497,9 @@ public function applyEvents(L1 $l1) // Delete the L1 entry, if any, when we fail to unserialize. $l1->delete($event->event_id, $address); } else { - $event->value = $unserialized_value; - $address = new Address(); - $address->unserialize($event->address); - $l1->setWithExpiration($event->event_id, $address, $event->value, $event->created, $event->expiration); + $l1->setWithExpiration($event->event_id, $address, $unserialized_value, $event->created, $event->expiration); } } - $last_applied_event_id = $event->event_id; $applied++; } diff --git a/src/Entry.php b/src/Entry.php index 222cabf..68d94b7 100644 --- a/src/Entry.php +++ b/src/Entry.php @@ -12,7 +12,19 @@ final class Entry public $expiration; public $tags; - public function __construct($event_id, $pool, Address $address, $value, $created, $expiration = null, array $tags = []) + /** + * + * @param type $event_id + * @param type $pool + * @param \LCache\Address $address + * @param type $value + * @param type $created + * @param type $expiration + * @param array|null $tags + * List of tag names for the entry object. + * Null, when loaded from some storage implementations. + */ + public function __construct($event_id, $pool, Address $address, $value, $created, $expiration = null, array $tags = null) { $this->event_id = $event_id; $this->pool = $pool; @@ -25,7 +37,7 @@ public function __construct($event_id, $pool, Address $address, $value, $created /** * Return the Address for this entry. - * @return Address + * @return \LCache\Address */ public function getAddress() { @@ -46,4 +58,10 @@ public function getTTL() } return 0; } + + public function isExpired() + { + return $this->expiration !== null + && $this->expiration < $_SERVER['REQUEST_TIME']; + } } diff --git a/src/Integrated.php b/src/Integrated.php index c51f4d5..698e351 100644 --- a/src/Integrated.php +++ b/src/Integrated.php @@ -2,10 +2,21 @@ namespace LCache; -final class Integrated +class Integrated { + /** @var L1 Managed L1 instance. */ protected $l1; + + /** @var L2 Managed L2 instance. */ protected $l2; + + /** + * @var int|null + * Defaults to NULL - disable the management logic for this functionality. + * Key overhead tuning parameter. Used to handle the auto-banning of keys + * with excessive write operations against them - degrading the overall + * library / cache efficiency. + */ protected $overhead_threshold; public function __construct(L1 $l1, L2 $l2, $overhead_threshold = null) @@ -17,13 +28,13 @@ public function __construct(L1 $l1, L2 $l2, $overhead_threshold = null) public function set(Address $address, $value, $ttl_or_expiration = null, array $tags = []) { + $now = $_SERVER['REQUEST_TIME']; + $expiration = null; if (!is_null($ttl_or_expiration)) { - if ($ttl_or_expiration < $_SERVER['REQUEST_TIME']) { - $expiration = $_SERVER['REQUEST_TIME'] + $ttl_or_expiration; - } else { - $expiration = $ttl_or_expiration; - } + $expiration = (int) $ttl_or_expiration; + // Consider any stale cache as TTL input. + $expiration += ($expiration < $now ? $now : 0); } if (!is_null($this->overhead_threshold)) { @@ -44,8 +55,8 @@ public function set(Address $address, $value, $ttl_or_expiration = null, array $ // in L1 for a number of minutes equivalent to the number of // excessive sets over the threshold, plus one minute. if (!is_null($event_id)) { - $expiration = $_SERVER['REQUEST_TIME'] + ($excess + 1) * 60; - $this->l1->setWithExpiration($event_id, $address, null, $_SERVER['REQUEST_TIME'], $expiration); + $expiration = $now + ($excess + 1) * 60; + $this->l1->setWithExpiration($event_id, $address, null, $now, $expiration); } return $event_id; } @@ -92,6 +103,11 @@ public function get(Address $address) return $entry->value; } + public function getAddressesFortag($tag) + { + return $this->l2->getAddressesForTag($tag); + } + public function exists(Address $address) { $exists = $this->l1->exists($address); diff --git a/src/L1.php b/src/L1.php index 3029fe6..3b90715 100644 --- a/src/L1.php +++ b/src/L1.php @@ -39,6 +39,14 @@ public function getPool() return $this->pool; } + /** + * + * @param int $event_id + * @param \LCache\Address $address + * @param mixed|null $value + * @param int $expiration + * @return bool|null + */ public function set($event_id, Address $address, $value = null, $expiration = null) { return $this->setWithExpiration($event_id, $address, $value, $_SERVER['REQUEST_TIME'], $expiration); diff --git a/src/L1CacheFactory.php b/src/L1CacheFactory.php index 251da20..587be9f 100644 --- a/src/L1CacheFactory.php +++ b/src/L1CacheFactory.php @@ -14,18 +14,38 @@ */ class L1CacheFactory { + /** @var StateL1Factory */ + private $state; + + /** + * Factory class constructor. + * + * @param \LCache\StateL1Factory $state + * Factory to be used for creation of the related internal state manager + * instances for the different L1 drivers. + */ + public function __construct(StateL1Factory $state) + { + $this->state = $state; + } + /** - * L1 cache drivers const + * L1 cache drivers construction method. * * @todo Change the return value to L1CacheInterface * * @param string $driverName - * Name of the L1 driver implementation to create. One of the DRIVER_* - * class constants. + * Name of the L1 driver implementation to create. Invalid driver names + * passed here will be ignored and the static will be used as a fallback + * implementation. Currently available drivers are: + * - apcu + * - static + * - sqlite + * - null * @param string $customPool * Pool ID to use for the data separation. * - * @return L1 + * @return \LCache\L1 * Concrete instance that confirms to an L1 interface. */ public function create($driverName = null, $customPool = null) @@ -36,7 +56,6 @@ public function create($driverName = null, $customPool = null) $factoryName = 'create' . $driver; if (!method_exists($this, $factoryName)) { - // TODO: Decide on better fallback (if needed). $factoryName = 'createStatic'; } @@ -52,7 +71,7 @@ public function create($driverName = null, $customPool = null) */ protected function createAPCu($pool) { - return new APCuL1($pool, new StateL1APCu($pool)); + return new APCuL1($pool, $this->state->create('apcu', $pool)); } /** @@ -63,7 +82,7 @@ protected function createAPCu($pool) */ protected function createNull($pool) { - return new NullL1($pool, new StateL1Static()); + return new NullL1($pool, $this->state->create('null', $pool)); } /** @@ -74,7 +93,7 @@ protected function createNull($pool) */ protected function createStatic($pool) { - return new StaticL1($pool, new StateL1Static()); + return new StaticL1($pool, $this->state->create('static', $pool)); } /** @@ -85,11 +104,9 @@ protected function createStatic($pool) */ protected function createSQLite($pool) { - $hasApcu = function_exists('apcu_fetch'); - // TODO: Maybe implement StateL1SQLite class instead of NULL one. - $state = $hasApcu ? new StateL1APCu("sqlite-$pool") : new StateL1Static(); - $cache = new SQLiteL1($pool, $state); - return $cache; + $stateDriver = function_exists('apcu_fetch') ? 'apcu' : 'sqlite'; + $state = $this->state->create($stateDriver, "sqlite-$pool"); + return new SQLiteL1($pool, $state); } /** diff --git a/src/L2.php b/src/L2.php index 2ac5520..1f306c2 100644 --- a/src/L2.php +++ b/src/L2.php @@ -4,10 +4,87 @@ abstract class L2 extends LX { + /** + * Incremental update of the L1's state. + * + * Based on L1's internal tracker, finds a list of non-appled events. One + * by one applies them to the given L1 instance, so it can reach the final / + * correct state as the L2. + * + * @var L1 $l1 + * Instance to apply write events to. + */ abstract public function applyEvents(L1 $l1); + + /** + * Mutator of the L2 state. + * + * Writes a $value (assciated with $tags) on $address within $pool. The + * cache entry will be deemed invalid / expired, when $expires timestamp has + * passed he current time. + * + * @var string $pool + * Cache pool to work with. + * @var \LCache\Address $address + * The cache entry address to store into. + * @var mixed $value + * The value to store on $address. + * @var int $expiration + * Unix timestamp to mark the time point when the cache item becomes + * invalid. Defaults to NULL - permanent cache item. + * @var array $tags + * List of tag names (string) to associate with the cache item. This will + * allow clients to delete multiple cache items by tag. + * @var bool $value_is_serialized + * DO NOT USE IN CLIENT CODE. Defaults to FALSE. This affects internals + * for handling the $value parameter and it's only used for testing. + */ abstract public function set($pool, Address $address, $value = null, $expiration = null, array $tags = [], $value_is_serialized = false); - abstract public function delete($pool, Address $address); - abstract public function deleteTag(L1 $l1, $tag); + + /** + * Prepares a list of Address instances associated with the provided tag. + * + * @var string $tag + * Tag name to do the look-up with. + * + * @return array + * List of the address instances associated with the $tag. + */ abstract public function getAddressesForTag($tag); + + /** + * Utility to find the amount of expired cache items in the storage. + * + * @return int + * Number of expired items. + */ abstract public function countGarbage(); + + /** + * Delete cache items marked by $tag. + * + * @var string $tag + * Single tag name to use for looking up cache entries for deletition. + */ + abstract public function deleteTag(L1 $l1, $tag); + + /** + * Delete cache item from $pool on $address. + * + * Depending on Address's value it might be pool, bin or an item data to be + * deleted from the cache storage. + * + * @param string $pool + * Pool that the cache item was set in. + * @param \LCache\Address $address + * Address instance that the cache item resides on. + * + * @return mixed + * Event id of the operation. + */ + public function delete($pool, Address $address) + { + $event_id = $this->set($pool, $address); + return $event_id; + } } diff --git a/src/L2CacheFactory.php b/src/L2CacheFactory.php new file mode 100644 index 0000000..fa307a8 --- /dev/null +++ b/src/L2CacheFactory.php @@ -0,0 +1,110 @@ +options = []; + foreach ($driverOptions as $name => $options) { + $this->setDriverOptions($name, $options); + } + } + + /** + * Factory driver options mutator. + * + * Allows the configuration of driver options after factory instantiation. + * + * @param array $options + * Options keyed by driver name. + * Example: ['driver_1' => ['option_1' => 'value_1', ...], ...] + */ + public function setDriverOptions($name, $options) + { + $this->options[$name] = $options; + } + + /** + * Factory driver options accessor. + * + * @see L2CacheFactory::setDriverOptions() + * + * @return array + * The aggregated configurations data for all drivers. + */ + public function getDriverOptions($name) + { + return isset($this->options[$name]) ? $this->options[$name] : []; + } + + /** + * Factory method for the L2 hierarchy. + * + * @param string $name + * String driver name to instantiate. + * @param array $options + * Driver options to overwrite the ones already present for the driver + * within the factory class. + * @return L2 + * Concrete descendant of the L2 abstract. + */ + public function create($name, array $options = []) + { + $factoryName = 'create' . $name; + if (!method_exists($this, $factoryName)) { + $factoryName = 'createStatic'; + } + + $driverOptions = array_merge($this->getDriverOptions($name), $options); + $l1CacheInstance = call_user_func([$this, $factoryName], $driverOptions); + return $l1CacheInstance; + } + + protected function createStatic($options) + { + return new StaticL2(); + } + + /** + * Possible options: + * - handle - the PDO handle instance. + * - prefix - tables prefix to use (default - ''). + * - log - whether to log errors locally in the instance (default - false). + * + * @param array $options + * @return \LCache\DatabaseL2 + */ + protected function createDatabase($options) + { + // Apply defaults. + $options += ['prefix' => '', 'log' => false]; + return new DatabaseL2( + $options['handle'], + $options['prefix'], + $options['log'] + ); + } +} diff --git a/src/LX.php b/src/LX.php index 24cb405..a2367d7 100644 --- a/src/LX.php +++ b/src/LX.php @@ -7,12 +7,42 @@ */ abstract class LX { + /** + * Get a cache entry based on address instance. + * + * Note that the Entry objet might be incomplete. Depending on driver + * implementation the tags property might be empty (null), as it could be + * non-optimal to load the tags with the entry object. + * + * @param \LCache\Address $address + * Address to lookup the entry. + * @return \LCache\Entry|null + * The entry found or null (on cache miss). + * + * @throws UnserializationException + * When the data stored in cache is in invalid format. + */ abstract public function getEntry(Address $address); + + /** + * Accessor for the aggregated value of cache-hit events on the driver. + * + * @return int + * The cache-hits count. + */ abstract public function getHits(); + + /** + * Accessor for the aggregated value of cache-miss events on the driver. + * + * @return int + * The cache-misses count. + */ abstract public function getMisses(); /** * Fetch a value from the cache. + * * @param Address $address * @return string|null */ @@ -27,6 +57,7 @@ public function get(Address $address) /** * Determine whether or not the specified Address exists in the cache. + * * @param Address $address * @return boolean */ @@ -36,6 +67,16 @@ public function exists(Address $address) return !is_null($value); } + /** + * Clears what's pobbible from the cache storage. + * + * @param int $item_limit + * Maximum number of items to remove. Defaults clear as much as possible. + * + * @return int|false + * Number of items cleared from the cache storage. + * False on error in the clean-up process. + */ public function collectGarbage($item_limit = null) { return 0; diff --git a/src/SQLiteL1.php b/src/SQLiteL1.php index 45a7867..2034d26 100644 --- a/src/SQLiteL1.php +++ b/src/SQLiteL1.php @@ -36,7 +36,14 @@ protected static function schemaStatements() { return [ // Table creation. - 'CREATE TABLE IF NOT EXISTS entries("address" TEXT PRIMARY KEY, "value" BLOB, "expiration" INTEGER, "created" INTEGER, "event_id" INTEGER NOT NULL DEFAULT 0, "reads" INTEGER NOT NULL DEFAULT 0, "writes" INTEGER NOT NULL DEFAULT 0)', + 'CREATE TABLE IF NOT EXISTS entries(' + . ' "address" TEXT PRIMARY KEY, ' + . ' "value" BLOB, "expiration" INTEGER, ' + . ' "created" INTEGER, ' + . ' "event_id" INTEGER NOT NULL DEFAULT 0, ' + . ' "reads" INTEGER NOT NULL DEFAULT 0, ' + . ' "writes" INTEGER NOT NULL DEFAULT 0' + . ')', // Index creation. 'CREATE INDEX IF NOT EXISTS expiration ON entries ("expiration")', @@ -63,9 +70,10 @@ public function __construct($pool, StateL1Interface $state) protected function pruneExpiredEntries() { - $sth = $this->dbh->prepare('DELETE FROM entries WHERE expiration < :now'); - $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); try { + $sql = 'DELETE FROM entries WHERE expiration < :now'; + $sth = $this->dbh->prepare($sql); + $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); $sth->execute(); // @codeCoverageIgnoreStart } catch (\PDOException $e) { @@ -109,7 +117,11 @@ public function setWithExpiration($event_id, Address $address, $value, $created, $serialized_value = serialize($value); } - $sth = $this->dbh->prepare('INSERT OR IGNORE INTO entries ("address", "value", "expiration", "created", "event_id", "writes") VALUES (:address, :value, :expiration, :created, :event_id, 1)'); + $sth = $this->dbh->prepare('' + . 'INSERT OR IGNORE INTO entries ' + . '("address", "value", "expiration", "created", "event_id", "writes") ' + . 'VALUES ' + . '(:address, :value, :expiration, :created, :event_id, 1)'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':value', $serialized_value, \PDO::PARAM_LOB); $sth->bindValue(':expiration', $expiration, \PDO::PARAM_INT); @@ -130,7 +142,15 @@ public function setWithExpiration($event_id, Address $address, $value, $created, // read (which creates a row with a read count of one) and then we // still miss L2 (which creates an L1 tombstone here), the update // goes through. - $sth = $this->dbh->prepare('UPDATE entries SET "value" = :value, "expiration" = :expiration, "created" = :created, "event_id" = :event_id ' . $bump_writes . ' WHERE "address" = :address AND ("event_id" < :event_id OR "event_id" = 0)'); + $sth = $this->dbh->prepare('' + . 'UPDATE entries SET ' + . ' "value" = :value, ' + . ' "expiration" = :expiration, ' + . ' "created" = :created, ' + . ' "event_id" = :event_id ' + . " $bump_writes " + . 'WHERE "address" = :address ' + . ' AND ("event_id" < :event_id OR "event_id" = 0)'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':value', $serialized_value, \PDO::PARAM_LOB); $sth->bindValue(':expiration', $expiration, \PDO::PARAM_INT); @@ -144,7 +164,12 @@ public function setWithExpiration($event_id, Address $address, $value, $created, public function exists(Address $address) { - $sth = $this->dbh->prepare('SELECT COUNT(*) AS existing FROM entries WHERE "address" = :address AND ("expiration" >= :now OR "expiration" IS NULL) AND "value" IS NOT NULL'); + $sth = $this->dbh->prepare('' + . 'SELECT COUNT(*) AS existing ' + . 'FROM entries ' + . 'WHERE "address" = :address ' + . ' AND ("expiration" >= :now OR "expiration" IS NULL) ' + . ' AND "value" IS NOT NULL'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); $sth->execute(); @@ -154,7 +179,12 @@ public function exists(Address $address) public function isNegativeCache(Address $address) { - $sth = $this->dbh->prepare('SELECT COUNT(*) AS entry_count FROM entries WHERE "address" = :address AND ("expiration" >= :now OR "expiration" IS NULL) AND "value" IS NULL'); + $sth = $this->dbh->prepare('' + . 'SELECT COUNT(*) AS entry_count ' + . 'FROM entries ' + . 'WHERE "address" = :address ' + . ' AND ("expiration" >= :now OR "expiration" IS NULL) ' + . ' AND "value" IS NULL'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); $sth->execute(); @@ -178,7 +208,11 @@ public function debugDumpState() public function getEntry(Address $address) { - $sth = $this->dbh->prepare('SELECT "value", "expiration", "reads", "writes", "created" FROM entries WHERE "address" = :address AND ("expiration" >= :now OR "expiration" IS NULL)'); + $sth = $this->dbh->prepare('' + . 'SELECT "value", "expiration", "reads", "writes", "created" ' + . 'FROM entries ' + . 'WHERE "address" = :address ' + . ' AND ("expiration" >= :now OR "expiration" IS NULL)'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':now', $_SERVER['REQUEST_TIME'], \PDO::PARAM_INT); $sth->execute(); @@ -189,13 +223,18 @@ public function getEntry(Address $address) // record reads after they massively outweigh writes for an address. // @TODO: Make this adapt to overhead thresholds. if (false === $entry || $entry->reads < 10 * $entry->writes || $entry->reads < 10) { - $sth = $this->dbh->prepare('UPDATE entries SET "reads" = "reads" + 1 WHERE "address" = :address'); + $sql = 'UPDATE entries SET "reads" = "reads" + 1 WHERE "address" = :address'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->execute(); if ($sth->rowCount() === 0) { // Use a zero expiration so this row is only used for counts, not negative caching. // Use the default event ID of zero to ensure any writes win over this stub. - $sth = $this->dbh->prepare('INSERT OR IGNORE INTO entries ("address", "expiration", "reads") VALUES (:address, 0, 1)'); + $sth = $this->dbh->prepare('' + . 'INSERT OR IGNORE INTO entries ' + . '("address", "expiration", "reads") ' + . 'VALUES ' + . '(:address, 0, 1)'); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->execute(); } @@ -219,7 +258,8 @@ public function delete($event_id, Address $address) { if ($address->isEntireCache() || $address->isEntireBin()) { $pattern = $address->serialize() . '%'; - $sth = $this->dbh->prepare('DELETE FROM entries WHERE "address" LIKE :pattern'); + $sql = 'DELETE FROM entries WHERE "address" LIKE :pattern'; + $sth = $this->dbh->prepare($sql); $sth->bindValue('pattern', $pattern, \PDO::PARAM_STR); $sth->execute(); @@ -227,7 +267,8 @@ public function delete($event_id, Address $address) return true; } - $sth = $this->dbh->prepare('DELETE FROM entries WHERE "address" = :address AND event_id < :event_id'); + $sql = 'DELETE FROM entries WHERE "address" = :address AND event_id < :event_id'; + $sth = $this->dbh->prepare($sql); $sth->bindValue(':address', $address->serialize(), \PDO::PARAM_STR); $sth->bindValue(':event_id', $event_id, \PDO::PARAM_INT); $sth->execute(); diff --git a/src/StateL1APCu.php b/src/StateL1APCu.php index f2da36f..0f262e7 100644 --- a/src/StateL1APCu.php +++ b/src/StateL1APCu.php @@ -117,6 +117,9 @@ public function getLastAppliedEventID() */ public function setLastAppliedEventID($eventId) { + if ($eventId < (int) $this->getLastAppliedEventID()) { + return false; + } return apcu_store($this->statusKeyLastAppliedEventId, $eventId); } @@ -125,7 +128,8 @@ public function setLastAppliedEventID($eventId) */ public function clear() { - apcu_store($this->statusKeyHits, 0); - apcu_store($this->statusKeyMisses, 0); + $hits = apcu_store($this->statusKeyHits, 0); + $misses = apcu_store($this->statusKeyMisses, 0); + return $hits && $misses; } } diff --git a/src/StateL1Factory.php b/src/StateL1Factory.php new file mode 100644 index 0000000..c7ba58a --- /dev/null +++ b/src/StateL1Factory.php @@ -0,0 +1,79 @@ +forceDriver = $forceDriver ? (string) $forceDriver : null; + } + + /** + * L1 State manager facotry method. + * + * @param string $driverName + * Name of the L1 state driver implementation to create. Invalid driver + * names will fall-back to the static driver implementation. Currently + * available drivers are: + * - apcu + * - static + * @param string $pool + * Pool ID to use for the data separation. + * + * @return \LCache\StateL1Interface + * Concrete instance that confirms to the state interface for L1. + */ + public function create($driverName = null, $pool = null) + { + $driver = $this->forceDriver ? $this->forceDriver : $driverName; + $factoryName = 'create' . mb_convert_case($driver, MB_CASE_LOWER); + if (!method_exists($this, $factoryName)) { + $factoryName = 'createStatic'; + } + + $l1CacheInstance = call_user_func([$this, $factoryName], $pool); + return $l1CacheInstance; + } + + /** + * Factory method for the APCu driver. + * + * @param string $pool + * @return \LCache\StateL1APCu + */ + private function createAPCu($pool) + { + return new StateL1APCu($pool); + } + + /** + * Factory method for the static driver. + * + * @param string $pool + * @return \LCache\StateL1APCu + */ + private function createStatic($pool) + { + return new StateL1Static(); + } +} diff --git a/src/StateL1Interface.php b/src/StateL1Interface.php index dd2dd6d..6ac0b42 100644 --- a/src/StateL1Interface.php +++ b/src/StateL1Interface.php @@ -22,6 +22,9 @@ interface StateL1Interface { /** * Records a cache-hit event in the driver. + * + * @return bool + * TRUE on success. */ public function recordHit(); @@ -35,6 +38,9 @@ public function getHits(); /** * Records a cache-miss event in the driver. + * + * @return bool + * TRUE on success. */ public function recordMiss(); @@ -49,11 +55,17 @@ public function getMisses(); /** * Stores the last applied cache mutation event id in the L1 cache. * - * This is needed, so on consecuitive requests, we should apply all events - * newer than this one. + * This is needed, so on consecuitive requests we can incrementally update + * the storage data. Clients should apply all events newer than this one. + * When an older event is passed, it will be ignored. * * @param int $eventId * Event ID to store for future reference. + * + * @return bool + * TRUE on successful assignment, FALSE when any of the following happens: + * - Failed to write the state to the storage. + * - Client atempted to write older event than the already stored one. */ public function setLastAppliedEventID($eventId); @@ -70,7 +82,8 @@ public function getLastAppliedEventID(); /** * Clears the collected statistical data. * - * @todo: Should the last applied event be cleared as well? + * @return bool + * TRUE on success, FALSE otherwise. */ public function clear(); } diff --git a/src/StateL1Static.php b/src/StateL1Static.php index 9c02ad8..cf729e6 100644 --- a/src/StateL1Static.php +++ b/src/StateL1Static.php @@ -38,6 +38,7 @@ public function __construct() public function recordHit() { $this->hits++; + return true; } /** @@ -46,6 +47,7 @@ public function recordHit() public function recordMiss() { $this->misses++; + return true; } /** @@ -77,6 +79,9 @@ public function getLastAppliedEventID() */ public function setLastAppliedEventID($eventId) { + if ($eventId < (int) $this->getLastAppliedEventID()) { + return false; + } $this->last_applied_event_id = $eventId; return true; } @@ -87,5 +92,6 @@ public function setLastAppliedEventID($eventId) public function clear() { $this->hits = $this->misses = 0; + return true; } } diff --git a/src/StaticL1.php b/src/StaticL1.php index c340608..2cc2761 100644 --- a/src/StaticL1.php +++ b/src/StaticL1.php @@ -57,7 +57,13 @@ public function setWithExpiration($event_id, Address $address, $value, $created, public function isNegativeCache(Address $address) { $local_key = $address->serialize(); - return (isset($this->storage[$local_key]) && is_null($this->storage[$local_key]->value)); + + $is_negative_cache = isset($this->storage[$local_key]) + && ($entry = $this->storage[$local_key]) + && null === $entry->value + && !$entry->isExpired(); + + return $is_negative_cache; } public function getEntry(Address $address) diff --git a/src/StaticL2.php b/src/StaticL2.php index ffb7566..1fc6fc8 100644 --- a/src/StaticL2.php +++ b/src/StaticL2.php @@ -4,6 +4,23 @@ class StaticL2 extends L2 { + /** + * @var int Shared static counter for the events managed by the driver. + */ + private static $currentEventId = 0; + + /** + * @var array Shared static collection that will contain all of the events. + */ + private static $allEvents = []; + + /** + * @var array + * Shared static collection that will contain for all managed cache tags. + */ + private static $allTags = []; + + protected $events; protected $current_event_id; protected $hits; @@ -12,11 +29,25 @@ class StaticL2 extends L2 public function __construct() { - $this->events = array(); - $this->current_event_id = 0; + // Share the data + $this->current_event_id = &self::$currentEventId; + $this->events = &self::$allEvents; + $this->tags = &self::$allTags; + $this->hits = 0; $this->misses = 0; - $this->tags = []; + } + + /** + * Testing utility. + * + * Used to reset the shared static state during a single proccess execution. + */ + public static function resetStorageState() + { + static::$allTags = []; + static::$allEvents = []; + static::$currentEventId = 0; } public function countGarbage() @@ -42,9 +73,13 @@ public function collectGarbage($item_limit = null) break; } } + return $deleted; } - // Returns an LCache\Entry + + /** + * {inheritDock} + */ public function getEntry(Address $address) { $events = array_filter($this->events, function (Entry $entry) use ($address) { @@ -85,21 +120,32 @@ public function set($pool, Address $address, $value = null, $expiration = null, // Serialize the value if it isn't already. We serialize the values // in static storage to make it more similar to other persistent stores. - if (!$value_is_serialized) { + if (!$value_is_serialized && !is_null($value)) { $value = serialize($value); } + + // Add the new address event entry. $this->events[$this->current_event_id] = new Entry($this->current_event_id, $pool, $address, $value, $_SERVER['REQUEST_TIME'], $expiration); - // Clear existing tags linked to the item. This is much more - // efficient with database-style indexes. - foreach ($this->tags as $tag => $addresses) { - $addresses_to_keep = []; - foreach ($addresses as $current_address) { - if ($address !== $current_address) { - $addresses_to_keep[] = $current_address; - } + // Prunning older events to reduce the driver's memory needs. + $addressEvents = array_filter($this->events, function (Entry $entry) use ($address) { + return $entry->getAddress()->isMatch($address); + }); + foreach ($addressEvents as $event_to_delete) { + /* @var $event_to_delete Entry */ + if ($event_to_delete->event_id < $this->current_event_id) { + unset($this->events[$event_to_delete->event_id]); } - $this->tags[$tag] = $addresses_to_keep; + } + unset($addressEvents, $event_to_delete); + + // Clear existing tags linked to the item. + // This is much more efficient with database-style indexes. + $filter = function ($current) use ($address) { + return $address !== $current; + }; + foreach ($this->tags as $tag => $addresses) { + $this->tags[$tag] = array_filter($addresses, $filter); } // Set the tags on the new item. @@ -114,12 +160,34 @@ public function set($pool, Address $address, $value = null, $expiration = null, return $this->current_event_id; } - public function delete($pool, Address $address) + /** + * Implemented based on the one in DatabaseL2 class (unused). + * + * @param int $eventId + * @return Entry + */ + public function getEvent($eventId) { - if ($address->isEntireCache()) { - $this->events = array(); + if (!isset($this->events[$eventId])) { + return null; } - return $this->set($pool, $address, null, null, [], true); + $event = clone $this->events[$eventId]; + $event->value = unserialize($event->value); + return $event; + } + + /** + * Removes replaced events from storage. + * + * @return boolean + * True on success. + */ + public function pruneReplacedEvents() + { + // No pruning needed in this driver. + // In the end of the request, everyhting is killed. + // Clean-up is sinchronous in the set method. + return true; } public function getAddressesForTag($tag) @@ -130,12 +198,14 @@ public function getAddressesForTag($tag) public function deleteTag(L1 $l1, $tag) { // Materialize the tag deletion as individual key deletions. + $event_id = null; + $pool = $l1->getPool(); foreach ($this->getAddressesForTag($tag) as $address) { - $event_id = $this->delete($l1->getPool(), $address); + $event_id = $this->delete($pool, $address); $l1->delete($event_id, $address); } unset($this->tags[$tag]); - return $this->current_event_id; + return $event_id; } public function applyEvents(L1 $l1) diff --git a/tests/EntryTest.php b/tests/EntryTest.php index d49210f..3769e91 100644 --- a/tests/EntryTest.php +++ b/tests/EntryTest.php @@ -12,12 +12,36 @@ class EntryTest extends \PHPUnit_Framework_TestCase public function testEntryTTL() { - $myaddr = new Address('mybin', 'mykey'); - $entry = new Entry(0, 'mypool', $myaddr, 'value', $_SERVER['REQUEST_TIME'], $_SERVER['REQUEST_TIME'] + 1); - $this->assertEquals(1, $entry->getTTL()); + $this->assertEquals(1, $this->getEntry(1)->getTTL()); // TTL should be zero for already-expired items. - $old_entry = new Entry(0, 'mypool', $myaddr, 'value', $_SERVER['REQUEST_TIME'], $_SERVER['REQUEST_TIME'] - 1); - $this->assertEquals(0, $old_entry->getTTL()); + $this->assertEquals(0, $this->getEntry(-1)->getTTL()); + + // TODO: How to classify this type of item? + $this->assertEquals(0, $this->getEntry(0)->getTTL()); + } + + public function testExpiry() + { + $this->assertTrue($this->getEntry(-1)->isExpired()); + $this->assertFalse($this->getEntry(1)->isExpired()); + + // TODO: How to classify this one? + $this->assertFalse($this->getEntry(0)->isExpired()); + } + + /** + * Entry objects factory needed for testing purposes. + * + * @param int $ttl + * @return \LCache\Entry + */ + protected function getEntry($ttl = 0) + { + $pool = 'my-pool'; + $now = $_SERVER['REQUEST_TIME']; + $address = new Address('bin', 'key'); + $entry = new Entry(0, $pool, $address, 'value', $now, $now + $ttl); + return $entry; } } diff --git a/tests/IntegrationCacheTest.php b/tests/IntegrationCacheTest.php new file mode 100644 index 0000000..c96412c --- /dev/null +++ b/tests/IntegrationCacheTest.php @@ -0,0 +1,661 @@ +dbTraitSetUp(); + \LCache\StaticL2::resetStorageState(); + $this->createSchema(); + } + + public function supportedL1Drivers() + { + return ['static', 'apcu', 'sqlite']; + } + + public function supportedL2Drivers($name = null) + { + $data = [ + 'static' => [], + 'database' => [ + 'handle' => $this->dbh, + 'log' => $this->dbErrorsLog, + ], + ]; + return $name ? $data[$name] : $data; + } + + public function createStateL1Factory() + { + return new StateL1Factory(); + } + + public function createL1Factory($state) + { + return new L1CacheFactory($state); + } + + /** + * + * @param string $name + * @param string $pool + * @return L1 + */ + public function createL1($name, $pool = null) + { + $state = $this->createStateL1Factory(); + $factory = $this->createL1Factory($state); + $l1 = $factory->create($name, $pool); + return $l1; + } + + /** + * @return L2 + */ + protected function createL2($name) + { + $options = $this->supportedL2Drivers($name); + $factory = new L2CacheFactory([$name => $options]); + $l2 = $factory->create($name); + return $l2; + } + + abstract protected function getDriverInstance(L1 $l1, L2 $l2, $threshold = null); + + /** + * + * @param type $l1Name + * @param type $l2Name + * @param type $threshold + * @return Integrated + */ + public function createPool($l1Name, $l2Name, array $options = []) + { + $options += [ + 'l1-pool' => null, + 'integrated-threshold' => null, + ]; + $l1 = $this->createL1($l1Name, $options['l1-pool']); + $l2 = $this->createL2($l2Name); + $pool = $this->getDriverInstance($l1, $l2, $options['integrated-threshold']); + return $pool; + } + + public function l1Provider() + { + $result = []; + foreach ($this->supportedL1Drivers() as $l1) { + $result["L1 driver: $l1"] = [$l1]; + } + return $result; + } + + public function poolProvider() + { + return $this->poolProviderHelper($this->supportedL2Drivers()); + } + + public function poolProviderForLimitedGarbageCollecionSupport() + { + $allL2 = $this->supportedL2Drivers(); + unset($allL2['database']); + return $this->poolProviderHelper($allL2); + } + + protected function poolProviderHelper($customL2) + { + $results = []; + $allL1 = $this->supportedL1Drivers(); + foreach (array_keys($customL2) as $l2) { + foreach ($allL1 as $l1) { + $results["Integrating L1:$l1 and L2:$l2"] = [$l1, $l2]; + } + } + return $results; + } + + public function twoPoolsProvider() + { + $results = []; + $allL1 = $this->supportedL1Drivers(); + foreach (array_keys($this->supportedL2Drivers()) as $l2) { + foreach ($allL1 as $l11) { + foreach ($allL1 as $l12) { + $name = "Pool-1 L1:$l11-L2:$l2 and Pool-2 L1:$l12-L2:$l2"; + $results[$name] = [$l2, $l11, $l12]; + } + } + } + return $results; + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testNewPoolSynchronization($l1Name, $l2Name) + { + $myaddr = new Address('mybin', 'mykey'); + + // Initialize sync for Pool 1. + $pool1 = $this->createPool($l1Name, $l2Name); + $this->assertNull($pool1->synchronize()); + $current_event_id = $pool1->getLastAppliedEventID(); + $this->assertEquals(0, $current_event_id); + + // Add a new entry to Pool 1. The last applied event should be our + // change. However, because the event is from the same pool, applied + // should be zero. + $pool1->set($myaddr, 'myvalue'); + $this->assertEquals(0, $pool1->synchronize()); + $this->assertEquals($current_event_id + 1, $pool1->getLastAppliedEventID()); + + // Add a new pool. Sync should return NULL applied changes but should + // bump the last applied event ID. + $pool2 = $this->createPool($l1Name, $l2Name); + $this->assertNull($pool2->synchronize()); + $this->assertEquals($pool1->getLastAppliedEventID(), $pool2->getLastAppliedEventID()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testCreation($l1Name, $l2Name) + { + $pool = $this->createPool($l1Name, $l2Name); + + // Empty L1 state. + $this->assertEquals(0, $pool->getHitsL1()); + $this->assertEquals(0, $pool->getMissesL1()); + $this->assertNull($pool->getLastAppliedEventID()); + $this->assertEquals(0, $pool->collectGarbageL1()); + + // Empty L2 state. + $this->assertEquals(0, $pool->getHitsL2()); + $this->assertEquals(0, $pool->getMissesL2()); + $this->assertEquals(0, $pool->collectGarbageL2()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testTombstone($l1Name, $l2Name) + { + $pool = $this->createPool($l1Name, $l2Name); + $address = new Address('mypool', 'mykey-dne'); + + // This should create a tombstone, after missing both L1 and L2. + $this->assertNull($pool->get($address)); + $this->assertEquals(1, $pool->getMissesL1()); + $this->assertEquals(1, $pool->getMissesL2()); + $this->assertEquals(0, $pool->getHitsL1()); + $this->assertEquals(0, $pool->getHitsL2()); + + // Forecully get it and assert only a HIT in L1. + $tombstone = $pool->getEntry($address, true); + $this->assertNotNull($tombstone); + $this->assertNull($tombstone->value); + $this->assertEquals(1, $pool->getMissesL1()); + $this->assertEquals(1, $pool->getMissesL2()); + $this->assertEquals(1, $pool->getHitsL1()); + $this->assertEquals(0, $pool->getHitsL2()); + + // The tombstone should also count as non-existence. + $this->assertFalse($pool->exists($address)); + + // This is a no-op for most L1 implementations, but it should not + // return false, regardless. + $this->assertTrue(false !== $pool->collectGarbage()); + } + + /** + * @group integration + * @dataProvider twoPoolsProvider + */ + public function testSynchronization($central, $l1First, $l1Second) + { + // Create two integrated pools with independent L1s. + $pool1 = $this->createPool($l1First, $central); + $pool2 = $this->createPool($l1Second, $central); + + $myaddr = new Address('mybin', 'mykey'); + + // Set and get an entry in Pool 1. + $pool1->set($myaddr, 'myvalue'); + $this->assertEquals('myvalue', $pool1->get($myaddr)); + $this->assertEquals(1, $pool1->getHitsL1()); + $this->assertEquals(0, $pool1->getHitsL2()); + $this->assertEquals(0, $pool1->getMisses()); + + // Read the entry in Pool 2. + $this->assertEquals('myvalue', $pool2->get($myaddr)); + $this->assertEquals(0, $pool2->getHitsL1()); + $this->assertEquals(1, $pool2->getHitsL2()); + $this->assertEquals(0, $pool2->getMisses()); + + // Initialize Pool 2 synchronization. + $changes = $pool2->synchronize(); + $this->assertNull($changes); + $this->assertEquals(1, $pool2->getLastAppliedEventID()); + + // Alter the item in Pool 1. Pool 2 should hit its L1 again + // with the out-of-date item. Synchronizing should fix it. + $pool1->set($myaddr, 'myvalue2'); + $this->assertEquals('myvalue', $pool2->get($myaddr)); + $applied = $pool2->synchronize(); + $this->assertEquals(1, $applied); + $this->assertEquals('myvalue2', $pool2->get($myaddr)); + + // Delete the item in Pool 1. Pool 2 should hit its L1 again + // with the now-deleted item. Synchronizing should fix it. + $pool1->delete($myaddr); + $this->assertEquals('myvalue2', $pool2->get($myaddr)); + $applied = $pool2->synchronize(); + $this->assertEquals(1, $applied); + $this->assertNull($pool2->get($myaddr)); + + // Try to get an entry that has never existed. + $myaddr_nonexistent = new Address('mybin', 'mykeynonexistent'); + $this->assertNull($pool1->get($myaddr_nonexistent)); + + // Test out bins and clearing. + $mybin1_mykey = new Address('mybin1', 'mykey'); + $mybin1 = new Address('mybin1'); + $mybin2_mykey = new Address('mybin2', 'mykey'); + $pool1->set($mybin1_mykey, 'myvalue1'); + $pool1->set($mybin2_mykey, 'myvalue2'); + $pool2->synchronize(); + $pool1->delete($mybin1); + + // The deleted bin should be evident in pool1 but not in pool2. + $this->assertNull($pool1->get($mybin1_mykey)); + $this->assertEquals('myvalue2', $pool1->get($mybin2_mykey)); + $this->assertEquals('myvalue1', $pool2->get($mybin1_mykey)); + $this->assertEquals('myvalue2', $pool2->get($mybin2_mykey)); + + // Synchronizing should propagate the bin clearing to pool2. + $pool2->synchronize(); + $this->assertNull($pool2->get($mybin1_mykey)); + $this->assertEquals('myvalue2', $pool2->get($mybin2_mykey)); + } + + /** + * @group integration + * @dataProvider twoPoolsProvider + */ + public function testClearSynchronization($central, $first_l1, $second_l1) + { + // Create two integrated pools with independent L1s. + $pool1 = $this->createPool($first_l1, $central); + $pool2 = $this->createPool($second_l1, $central); + + $myaddr = new Address('mybin', 'mykey'); + + // Create an item, synchronize, and then do a complete clear. + $pool1->set($myaddr, 'mynewvalue'); + $this->assertEquals('mynewvalue', $pool1->get($myaddr)); + $pool2->synchronize(); + $this->assertEquals('mynewvalue', $pool2->get($myaddr)); + $pool1->delete(new Address()); + $this->assertNull($pool1->get($myaddr)); + + // Pool 2 should lag until it synchronizes. + $this->assertEquals('mynewvalue', $pool2->get($myaddr)); + $pool2->synchronize(); + $this->assertNull($pool2->get($myaddr)); + } + + /** + * @group integration + * @dataProvider twoPoolsProvider + */ + public function testTaggedSynchronization($central, $first_l1, $second_l1) + { + // Create two integrated pools with independent L1s. + $pool1 = $this->createPool($first_l1, $central); + $pool2 = $this->createPool($second_l1, $central); + + $myaddr = new Address('mybin', 'mykey'); + + // Test deleting a tag that doesn't exist yet. + $pool1->deleteTag('mytag'); + + // Set and get an entry in Pool 1. + $pool1->set($myaddr, 'myvalue', null, ['mytag']); + $this->assertEquals([$myaddr], $pool1->getAddressesForTag('mytag')); + $this->assertEquals('myvalue', $pool1->get($myaddr)); + $this->assertEquals(1, $pool1->getHitsL1()); + $this->assertEquals(0, $pool1->getHitsL2()); + $this->assertEquals(0, $pool1->getMisses()); + + // Read the entry in Pool 2. + $this->assertEquals('myvalue', $pool2->get($myaddr)); + $this->assertEquals(0, $pool2->getHitsL1()); + $this->assertEquals(1, $pool2->getHitsL2()); + $this->assertEquals(0, $pool2->getMisses()); + + + // Initialize Pool 2 synchronization. + $pool2->synchronize(); + + // Delete the tag. The item should now be missing from Pool 1. + $pool1->deleteTag('mytag'); // TKTK + $this->assertNull($pool1->get($myaddr)); + $this->assertEquals(1, $pool1->getMissesL1()); + $this->assertEquals(1, $pool1->getMissesL2()); + + + // Pool 2 should hit its L1 again with the tag-deleted item. + // Synchronizing should fix it. + $this->assertEquals('myvalue', $pool2->get($myaddr)); + $applied = $pool2->synchronize(); + $this->assertEquals(1, $applied); + $this->assertNull($pool2->get($myaddr)); + + // Ensure the addition of a second tag still works for deletion. + $myaddr2 = new Address('mybin', 'mykey2'); + $pool1->set($myaddr2, 'myvalue', null, ['mytag']); + $pool1->set($myaddr2, 'myvalue', null, ['mytag', 'mytag2']); + $pool1->deleteTag('mytag2'); + $this->assertNull($pool1->get($myaddr2)); + + // Ensure updating a second item with a tag doesn't remove it from the + // first. + $pool1->delete(new Address()); + $pool1->set($myaddr, 'myvalue', null, ['mytag', 'mytag2']); + $pool1->set($myaddr2, 'myvalue', null, ['mytag', 'mytag2']); + $pool1->set($myaddr, 'myvalue', null, ['mytag']); + + // The function getAddressesForTag() may return additional addresses, + // but it should always return at least the current tagged address. + $found_addresses = $pool2->getAddressesForTag('mytag2'); + $this->assertEquals([$myaddr2], array_values(array_filter($found_addresses, function ($addr) use ($myaddr2) { + return $addr->serialize() === $myaddr2->serialize(); + }))); + } + + /** + * @group integration + * @dataProvider l1Provider + */ + public function testBrokenDatabaseFallback($l1) + { + $this->dbErrorsLog = true; + + $myaddr = new Address('mybin', 'mykey'); + $myaddr2 = new Address('mybin', 'mykey2'); + $pool = $this->createPool($l1, 'database'); + + // Break the schema and try operations. + $this->dbh->exec('DROP TABLE lcache_tags'); + $this->assertNull($pool->set($myaddr, 'myvalue', null, ['mytag'])); + $this->assertGreaterThanOREqual(1, count($pool->getL2()->getErrors())); + $this->assertNull($pool->deleteTag('mytag')); + $pool->synchronize(); + + // Break + $this->dbh->exec('DROP TABLE lcache_events'); + $this->assertNull($pool->synchronize()); + $this->assertNull($pool->get($myaddr2)); + $this->assertNull($pool->exists($myaddr2)); + $this->assertNull($pool->set($myaddr, 'myvalue')); + $this->assertNull($pool->delete($myaddr)); + $this->assertNull($pool->delete(new Address())); + $this->assertNull($pool->getAddressesForTag('mytag')); + $this->assertNull($pool->getL2()->countGarbage()); + + // Try applying events to an uninitialized L1. + $pool2 = $this->createPool($l1, 'database'); + $this->assertNull($pool2->synchronize()); + + // Try garbage collection routines. + $this->assertEquals(0, $pool->collectGarbage()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testSyncWithNoWrites($l1, $l2) + { + $this->dbErrorsLog = true; + $this->assertNull($this->createPool($l1, $l2)->synchronize()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testExists($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $myaddr = new Address('mybin', 'mykey'); + + $this->assertNotNull($pool->set($myaddr, 'myvalue')); + $this->assertTrue($pool->exists($myaddr)); + $this->assertNotNull($pool->delete($myaddr)); + $this->assertFalse($pool->exists($myaddr)); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testPoolValueAccessor($l1, $l2) + { + $poolName = 'test-pool-name'; + $pool = $this->createPool($l1, $l2, ['l1-pool' => $poolName]); + $this->assertEquals($poolName, $pool->getPool()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testExpiration($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $myaddr = new Address('mybin', 'mykey'); + + $this->assertNotNull($pool->set($myaddr, 'value', 1)); + $this->assertEquals('value', $pool->get($myaddr)); + $this->assertEquals($_SERVER['REQUEST_TIME'] + 1, $pool->getEntry($myaddr)->expiration); + + // Setting an TTL/expiration more than request time should be treated + // as an expiration. + $this->assertNotNull($pool->set($myaddr, 'value', $_SERVER['REQUEST_TIME'] + 1)); + $this->assertEquals('value', $pool->get($myaddr)); + $this->assertEquals($_SERVER['REQUEST_TIME'] + 1, $pool->getEntry($myaddr)->expiration); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testExcessiveOverheadSkipping($l1, $l2) + { + $pool = $this->createPool($l1, $l2, ['integrated-threshold' => 2]); + $myaddr = new Address('mybin', 'mykey'); + + // These should go through entirely. + $this->assertNotNull($pool->set($myaddr, 'myvalue1')); + $this->assertNotNull($pool->set($myaddr, 'myvalue2')); + + // This should return an event_id but delete the item. + $this->assertEquals(2, $pool->getL1()->getKeyOverhead($myaddr)); + $this->assertFalse($pool->getL1()->isNegativeCache($myaddr)); + $this->assertNotNull($pool->set($myaddr, 'myvalue3')); + $this->assertFalse($pool->exists($myaddr)); + + // A few more sets to offset the existence check, which some L1s may + // treat as a hit. This should put us firmly in excessive territory. + $pool->set($myaddr, 'myvalue4'); + $pool->set($myaddr, 'myvalue5'); + $pool->set($myaddr, 'myvalue6'); + + // Now, with the local negative cache, these shouldn't even return + // an event_id. + $this->assertNull($pool->set($myaddr, 'myvalueA1')); + $this->assertNull($pool->set($myaddr, 'myvalueA2')); + + // Test a lot of sets but with enough hits to drop below the threshold. + $myaddr2 = new Address('mybin', 'mykey2'); + $this->assertNotNull($pool->set($myaddr2, 'myvalue')); + $this->assertNotNull($pool->set($myaddr2, 'myvalue')); + $this->assertEquals('myvalue', $pool->get($myaddr2)); + $this->assertEquals('myvalue', $pool->get($myaddr2)); + $this->assertNotNull($pool->set($myaddr2, 'myvalue')); + $this->assertNotNull($pool->set($myaddr2, 'myvalue')); + } + + /** + * $this overlaps with L1CacheTest::testStateStorage(). + * + * @group integration + * @dataProvider poolProvider + */ + public function testHitSetCounter($l1, $l2) + { + $myaddr = new Address('mybin', 'mykey'); + $pool = $this->createPool($l1, $l2); + $l1Driver = $pool->getL1(); + + $this->assertEquals(0, $l1Driver->getKeyOverhead($myaddr)); + $pool->set($myaddr, 'myvalue'); + $this->assertEquals(1, $l1Driver->getKeyOverhead($myaddr)); + $pool->get($myaddr); + $this->assertEquals(0, $l1Driver->getKeyOverhead($myaddr)); + $pool->set($myaddr, 'myvalue2'); + $this->assertEquals(1, $l1Driver->getKeyOverhead($myaddr)); + + // An unknown get should create negative overhead, generally + // in anticipation of a set. + $myaddr2 = new Address('mybin', 'mykey2'); + $this->assertNull($pool->get($myaddr2)); + $this->assertEquals(-1, $l1Driver->getKeyOverhead($myaddr2)); + } + + /** + * @group integration + * @dataProvider poolProvider + * @expectedException LCache\UnserializationException + */ + public function testFailedUnserializationOnGet($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $invalid_object = 'O:10:"HelloWorl":0:{}'; + $myaddr = new Address('mybin', 'performFailedUnserializationOnGetTest'); + + $pool->getL2()->set('anypool', $myaddr, $invalid_object, null, [], true); + $pool->get($myaddr); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testGarbageCollection($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $myaddr = new Address('mybin', 'mykey'); + + $this->assertEquals(0, $pool->getL2()->countGarbage()); + $pool->set($myaddr, 'myvalue', -1); + $this->assertEquals(1, $pool->getL2()->countGarbage()); + $pool->collectGarbage(); + $this->assertEquals(0, $pool->getL2()->countGarbage()); + } + + /** + * @group integration + * @dataProvider poolProviderForLimitedGarbageCollecionSupport + */ + public function testLimitedGarbageCollection($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $myaddr1 = new Address('mybin', 'mykey1'); + $myaddr2 = new Address('mybin', 'mykey2'); + + $this->assertEquals(0, $pool->collectGarbage()); + $pool->set($myaddr1, 'myvalue', -1); + $pool->set($myaddr2, 'myvalue', -1); + $this->assertEquals(2, $pool->getL2()->countGarbage()); + $pool->collectGarbage(1); + $this->assertEquals(1, $pool->getL2()->countGarbage()); + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testCaughtUnserializationOnGet($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $invalid_object = 'O:10:"HelloWorl":0:{}'; + $myaddr = new Address('mybin', 'performCaughtUnserializationOnGetTest'); + + // Set a broken Item directly. + $pool->getL2()->set('anypool', $myaddr, $invalid_object, null, [], true); + try { + $pool->get($myaddr); + + // Should not reach here. + $this->assertTrue(false); + } catch (UnserializationException $e) { + $this->assertEquals($invalid_object, $e->getSerializedData()); + + // The text of the exception should include the class name, bin, and key. + $this->assertRegExp('/^' . preg_quote('LCache\UnserializationException: Cache') . '/', strval($e)); + $this->assertRegExp('/bin "' . preg_quote($myaddr->getBin()) . '"/', strval($e)); + $this->assertRegExp('/key "' . preg_quote($myaddr->getKey()) . '"/', strval($e)); + } + } + + /** + * @group integration + * @dataProvider poolProvider + */ + public function testFailedUnserialization($l1, $l2) + { + $pool = $this->createPool($l1, $l2); + $myaddr = new Address('mybin', 'mykey'); + $invalid_object = 'O:10:"HelloWorl":0:{}'; + + // Set the L1's high water mark. + $pool->set($myaddr, 'valid'); + $changes = $pool->synchronize(); + $this->assertNull($changes); // Just initialized event high water mark. + $this->assertEquals(1, $pool->getLastAppliedEventID()); + + // Put an invalid object into the L2 and synchronize again. + $pool->getL2()->set('anotherpool', $myaddr, $invalid_object, null, [], true); + $changes = $pool->synchronize(); + $this->assertEquals(1, $changes); + $this->assertEquals(2, $pool->getLastAppliedEventID()); + + // The sync should delete the item from the L1, causing it to miss. + $this->assertNull($pool->getL1()->get($myaddr)); + $this->assertEquals(0, $pool->getHitsL1()); + $this->assertEquals(1, $pool->getMissesL1()); + } +} diff --git a/tests/L1/APCuTest.php b/tests/L1/APCuTest.php index e872be3..53670e6 100644 --- a/tests/L1/APCuTest.php +++ b/tests/L1/APCuTest.php @@ -36,4 +36,17 @@ protected function driverName() { return 'apcu'; } + + /** + * Marked as failing, as it differs from the base implementation. + * + * @group L1 + * @group failing + * @dataProvider stateDriverProvider + */ + public function testNegativeCache($state) + { + // TODO: Uncomment and run: composer test-failing +// parent::testNegativeCache($state); + } } diff --git a/tests/L1/NullTest.php b/tests/L1/NullTest.php index ffaa22f..ba9a851 100644 --- a/tests/L1/NullTest.php +++ b/tests/L1/NullTest.php @@ -1,9 +1,8 @@ createL1(); + $cache = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); $cache->set(1, $myaddr, 'myvalue'); @@ -35,29 +41,53 @@ public function testHitMiss() $this->assertEquals(1, $cache->getMisses()); } - public function testStateStorage() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testStateStorage($state) { - $lastEventId = $this->createL1()->getLastAppliedEventID(); + $lastEventId = $this->createL1($state)->getLastAppliedEventID(); $this->assertEquals(PHP_INT_MAX, $lastEventId); } + /** + * @group L1 + */ public function testSetGetDelete() { // Not relevant for NullL1 class. } + /** + * @group L1 + */ public function testPreventRollback() { // Not relevant for NullL1 class. } + /** + * @group L1 + */ public function testExists() { // Not relevant for NullL1 class. } + /** + * @group L1 + */ public function testPoolSharing() { // Not relevant for NullL1 class. } + + /** + * @group L1 + */ + public function testNegativeCache() + { + // Not relevant for NullL1 class. + } } diff --git a/tests/L1/State/APCuTest.php b/tests/L1/State/APCuTest.php new file mode 100644 index 0000000..79f57da --- /dev/null +++ b/tests/L1/State/APCuTest.php @@ -0,0 +1,21 @@ +create($this->driverName(), $pool); + $stateFactory = new StateL1Factory($state); + $l1Factory = new L1CacheFactory($stateFactory); + $l1 = $l1Factory->create($this->driverName(), $pool); + return $l1; } - public function testSetGetDelete() + public function stateDriverProvider() + { + return [ + 'State APCu' => ['apcu'], + 'State static' => ['static'], + ]; + } + + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testL1Factory($state) + { + $staticL1 = $this->createL1($state, 'static'); + $invalidL1 = $this->createL1($state, 'invalid_cache_driver'); + $this->assertEquals(get_class($staticL1), get_class($invalidL1)); + } + + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testSetGetDelete($state) { $event_id = 1; - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); // Validate emptyness. @@ -92,9 +121,13 @@ public function testSetGetDelete() $this->assertEquals(42, $entry->created); } - public function testPreventRollback() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testPreventRollback($state) { - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); $current_event_id = $l1->getLastAppliedEventID(); @@ -110,10 +143,14 @@ public function testPreventRollback() $this->assertEquals('myvalue', $l1->get($myaddr)); } - public function testFullDelete() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testFullDelete($state) { $event_id = 1; - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); // Set an entry and clear the storage. @@ -124,22 +161,30 @@ public function testFullDelete() $this->assertEquals(1, $l1->getMisses()); } - public function testExpiration() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testExpiration($state) { $event_id = 1; - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); - // Set and get an entry. + // Setting expired item does nothing. $l1->set($event_id++, $myaddr, 'myvalue', -1); $this->assertNull($l1->get($myaddr)); $this->assertEquals(0, $l1->getHits()); $this->assertEquals(1, $l1->getMisses()); } - public function testExists() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testExists($state) { - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); $l1->set(1, $myaddr, 'myvalue'); @@ -148,35 +193,47 @@ public function testExists() $this->assertFalse($l1->exists($myaddr)); } - public function testPoolIDs() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testPoolIDs($state) { // Test unique ID generation. - $this->assertNotNull($this->createL1()->getPool()); + $this->assertNotNull($this->createL1($state)->getPool()); // Test host-based generation. $_SERVER['SERVER_ADDR'] = 'localhost'; $_SERVER['SERVER_PORT'] = '80'; - $this->assertEquals('localhost-80', $this->createL1()->getPool()); + $this->assertEquals('localhost-80', $this->createL1($state)->getPool()); } - public function testPoolSharing() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testPoolSharing($state) { $value = 'myvalue'; $myaddr = new Address('mybin', 'mykey'); $poolName = uniqid('', true) . '-' . mt_rand(); // Initialize a value in cache. - $this->createL1($poolName)->set(1, $myaddr, $value); + $this->createL1($state, $poolName)->set(1, $myaddr, $value); // Opening a second instance of the same pool should work. // Reading from the second handle should show the same value. - $this->assertEquals($value, $this->createL1($poolName)->get($myaddr)); + $this->assertEquals($value, $this->createL1($state, $poolName)->get($myaddr)); } - public function testHitMiss() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testHitMiss($state) { $event_id = 1; - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); list($hits, $misses) = [$l1->getHits(), $l1->getMisses()]; @@ -188,10 +245,14 @@ public function testHitMiss() $this->assertEquals($hits + 1, $l1->getHits()); } - public function testStateStorage() + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testStateStorage($state) { $event_id = 1; - $l1 = $this->createL1(); + $l1 = $this->createL1($state); $myaddr = new Address('mybin', 'mykey'); $this->assertEquals(0, $l1->getKeyOverhead($myaddr)); @@ -208,4 +269,19 @@ public function testStateStorage() $l1->get($myaddr2); $this->assertEquals(-1, $l1->getKeyOverhead($myaddr2)); } + + /** + * @group L1 + * @dataProvider stateDriverProvider + */ + public function testNegativeCache($state) + { + $delta = 10; + $l1 = $this->createL1($state); + $now = $_SERVER['REQUEST_TIME']; + $myaddr = new Address('mybin', 'mykey'); + + $this->assertTrue($l1->set(1, $myaddr, null, $now - $delta)); + $this->assertFalse($l1->isNegativeCache($myaddr)); + } } diff --git a/tests/L2/DatabaseTest.php b/tests/L2/DatabaseTest.php new file mode 100644 index 0000000..6eb8497 --- /dev/null +++ b/tests/L2/DatabaseTest.php @@ -0,0 +1,66 @@ +createSchema($this->dbPrefix); + return ['database', [ + 'handle' => $this->dbh, + 'prefix' => $this->dbPrefix, + ]]; + } + + /** + * @group L2 + */ + public function testDatabaseL2Prefix() + { + $this->dbPrefix = 'myprefix_'; + $myaddr = new Address('mybin', 'mykey'); + + $l2 = $this->createL2(); + + $l2->set('mypool', $myaddr, 'myvalue', null, ['mytag']); + $this->assertEquals('myvalue', $l2->get($myaddr)); + } + + /** + * Marked as failing, as it differs from the base implementation. + * + * @see DatabaseTest::helperTestGarbageCollection() + * + * @group L2 + * @group failing + */ + public function testGarbageCollection() + { + parent::testGarbageCollection(); + } + + /** + * @todo Think about how to comply with the base implementation of this. + */ + protected function helperTestGarbageCollection(\LCache\L2 $l2) + { + $this->assertEquals(2, $l2->collectGarbage()); + $this->assertEquals(0, $l2->countGarbage()); + } +} diff --git a/tests/L2/StaticTest.php b/tests/L2/StaticTest.php new file mode 100644 index 0000000..655b247 --- /dev/null +++ b/tests/L2/StaticTest.php @@ -0,0 +1,29 @@ +l2FactoryOptions(); + $factory = new L2CacheFactory([$name => $options]); + $l2 = $factory->create($name); + return $l2; + } + + protected function suportedL1Drivers() + { + return ['apcu', 'static', 'sqlite']; + } + + /** + * Data provider for L1 driver names. + * + * @return array + */ + public function l1DriverNameProvider() + { + return array_map(function ($name) { + return [$name]; + }, $this->suportedL1Drivers()); + } + + /** + * + * @param string $driverName + * @param string $customPool + * @return L1 + */ + public function createL1($driverName, $customPool = null) + { + $state = new StateL1Factory(); + return (new L1CacheFactory($state))->create($driverName, $customPool); + } + + /** + * @group L2 + */ + public function testExistsHitMiss() + { + $l2 = $this->createL2(); + $myaddr = new Address('mybin', 'mykey'); + + $this->assertEquals(0, $l2->getHits()); + $this->assertEquals(0, $l2->getMisses()); + + $l2->set('mypool', $myaddr, 'myvalue'); + $this->assertTrue($l2->exists($myaddr)); + $this->assertEquals(1, $l2->getHits()); + + $l2->delete('mypool', $myaddr); + $this->assertFalse($l2->exists($myaddr)); + $this->assertEquals(1, $l2->getMisses()); + } + + /** + * @group L2 + */ + public function testGetEntryHitMiss() + { + $l2 = $this->createL2(); + $myaddr = new Address('mybin', 'mykey'); + + $this->assertEquals(0, $l2->getHits()); + $this->assertEquals(0, $l2->getMisses()); + + $l2->set('mypool', $myaddr, 'myvalue'); + $data = $l2->getEntry($myaddr); + + $this->assertTrue($data instanceof Entry); + $this->assertEquals(1, $l2->getHits()); + + $l2->delete('mypool', $myaddr); + $this->assertFalse($l2->get($myaddr) instanceof Entry); + $this->assertEquals(1, $l2->getMisses()); + } + + /** + * @group L2 + */ + public function testEmptyCleanUp() + { + $l2 = $this->createL2(); + } + + /** + * @group L2 + */ + public function testBatchDeletion() + { + $l2 = $this->createL2(); + + $myaddr = new Address('mybin', 'mykey'); + $l2->set('mypool', $myaddr, 'myvalue'); + + $mybin = new Address('mybin', null); + $l2->delete('mypool', $mybin); + + $this->assertNull($l2->get($myaddr)); + } + + /** + * @group L2 + */ + public function testL2Factory() + { + $factory = new L2CacheFactory(); + $staticL1 = $factory->create('static'); + $invalidL1 = $factory->create('invalid_cache_driver'); + $this->assertEquals(get_class($staticL1), get_class($invalidL1)); + } + + /** + * @group L2 + */ + public function testCleanupAfterWrite() + { + $myaddr = new Address('mybin', 'mykey'); + + // Write to the key with the first client. + $l2_client_a = $this->createL2(); + $event_id_a = $l2_client_a->set('mypool', $myaddr, 'myvalue'); + + // Verify that the first event exists and has the right value. + $event = $l2_client_a->getEvent($event_id_a); + $this->assertEquals('myvalue', $event->value); + + // Use a second client. This gives us a fresh event_id_low_water, + // just like a new PHP request. + $l2_client_b = $this->createL2(); + + // Write to the same key with the second client. + $event_id_b = $l2_client_b->set('mypool', $myaddr, 'myvalue2'); + + // Verify that the second event exists and has the right value. + $event = $l2_client_b->getEvent($event_id_b); + $this->assertEquals('myvalue2', $event->value); + + // Call the same method as on destruction. This second client should + // now prune any writes to the key from earlier requests. + $l2_client_b->pruneReplacedEvents(); + + // Verify that the first event no longer exists. + $event = $l2_client_b->getEvent($event_id_a); + $this->assertNull($event); + } + + /** + * @group L2 + * @dataProvider l1DriverNameProvider + */ + public function testApplyEvents($driverName) + { + // Init. + $value1 = 'test1'; + $value2 = 'test2'; + $address1 = new Address('bin', 'key1'); + $address2 = new Address('bin', 'key2'); + $l1_1 = $this->createL1($driverName); + $l1_2 = $this->createL1($driverName); + $l2 = $this->createL2(); + + // Empty L1 & L2. + $this->assertNull($l1_1->getLastAppliedEventID()); + $this->assertNull($l1_2->getLastAppliedEventID()); + $this->assertNull($l2->applyEvents($l1_1)); + $this->assertNull($l2->applyEvents($l1_2)); + $this->assertEquals(0, $l1_1->getLastAppliedEventID()); + $this->assertEquals(0, $l1_2->getLastAppliedEventID()); + + // Two writes to L2, one from each L1. + $this->assertEquals(1, $l2->set($l1_1->getPool(), $address1, $value1)); + $this->assertEquals(2, $l2->set($l1_2->getPool(), $address2, $value2)); + + // Validate state transfer L1.1 -> L1.2. + $this->assertEquals(1, $l2->applyEvents($l1_1)); + $this->assertEquals(2, $l1_1->getLastAppliedEventID()); + $this->assertEquals($value2, $l1_1->get($address2)); + + // Validate state transfer L1.2 -> L112. + $this->assertEquals(1, $l2->applyEvents($l1_2)); + $this->assertEquals(2, $l1_2->getLastAppliedEventID()); + $this->assertEquals($value1, $l1_2->get($address1)); + } + + /** + * @group L2 + * @dataProvider l1DriverNameProvider + */ + public function testDeleteTag($driverName) + { + $tag = 'test-tag'; + $value = 'test'; + $address = new Address('bin', 'key1'); + + $l1 = $this->createL1($driverName); + $l2 = $this->createL2(); + + // Init. + $event_id = $l2->set('some-pool', $address, $value, null, [$tag]); + $l1->set($event_id, $address, $value); + $this->assertEquals(1, $event_id); + $this->assertEquals($l1->get($address), $l2->get($address)); + + // Delete a single address, getting a new event id for it. + $this->assertEquals(2, $l2->deleteTag($l1, $tag)); + + // L1 data is cleared. + $this->assertNull($l1->get($address)); + + // Nothing more to delete for the tag. + $this->assertNull($l2->deleteTag($l1, $tag)); + } + + /** + * @group L2 + */ + public function testGarbageCollection() + { + $value = 'test'; + $l2 = $this->createL2(); + $expre = $_SERVER['REQUEST_TIME'] - 10; + + $this->assertEquals(0, $l2->countGarbage()); + $this->assertEquals(0, $l2->collectGarbage()); + + $this->assertEquals(1, $l2->set('some-pool', new Address('bin', 'key1'), $value, $expre)); + $this->assertEquals(2, $l2->set('some-pool', new Address('bin', 'key2'), $value, $expre)); + $this->assertEquals(2, $l2->countGarbage()); + + $this->helperTestGarbageCollection($l2); + } + + /** + * @see testGarbageCollection + * + * @param \LCache\L2 $l2 + */ + protected function helperTestGarbageCollection(\LCache\L2 $l2) + { + // Clean single stale item. + $this->assertEquals(1, $l2->collectGarbage(1)); + $this->assertEquals(1, $l2->countGarbage()); + + // Clean the rest. + $this->assertEquals(1, $l2->collectGarbage()); + $this->assertEquals(0, $l2->countGarbage()); + } + + /** + * @group L2 + */ + public function testClearPoolData() + { + $l2 = $this->createL2(); + $myaddr = new Address('mybin', 'mykey'); + + $l2->set('mypool', $myaddr, 'myvalue'); + $l2->delete('mypool', new Address()); + $this->assertNull($l2->get($myaddr)); + } + + /** + * @group L2 + */ + public function testExpiration() + { + $l2 = $this->createL2(); + $myaddr = new Address('mybin', 'mykey'); + + $l2->set('mypool', $myaddr, 'myvalue', -1); + $this->assertNull($l2->get($myaddr)); + } + + /** + * @group L2 + */ + public function testReread() + { + $l2 = $this->createL2(); + $myaddr = new Address('mybin', 'mykey'); + + $l2->set('mypool', $myaddr, 'myvalue'); + $this->assertEquals('myvalue', $l2->get($myaddr)); + $this->assertEquals('myvalue', $l2->get($myaddr)); + $this->assertEquals('myvalue', $l2->get($myaddr)); + $this->assertEquals('myvalue', $l2->get($myaddr)); + } +} diff --git a/tests/LCacheTest.php b/tests/LCacheTest.php deleted file mode 100644 index ce4812d..0000000 --- a/tests/LCacheTest.php +++ /dev/null @@ -1,817 +0,0 @@ -_factory === null) { - $this->_factory = new L1CacheFactory(); - } - return $this->_factory; - } - - /** - * @return PHPUnit_Extensions_Database_DB_IDatabaseConnection - */ - protected function getConnection() - { - $this->dbh = new \PDO('sqlite::memory:'); - $this->dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - return $this->createDefaultDBConnection($this->dbh, ':memory:'); - } - - protected function createSchema($prefix = '') - { - $this->dbh->exec('PRAGMA foreign_keys = ON'); - - $this->dbh->exec('CREATE TABLE ' . $prefix . 'lcache_events("event_id" INTEGER PRIMARY KEY AUTOINCREMENT, "pool" TEXT NOT NULL, "address" TEXT, "value" BLOB, "expiration" INTEGER, "created" INTEGER NOT NULL)'); - $this->dbh->exec('CREATE INDEX ' . $prefix . 'latest_entry ON ' . $prefix . 'lcache_events ("address", "event_id")'); - - // @TODO: Set a proper primary key and foreign key relationship. - $this->dbh->exec('CREATE TABLE ' . $prefix . 'lcache_tags("tag" TEXT, "event_id" INTEGER, PRIMARY KEY ("tag", "event_id"), FOREIGN KEY("event_id") REFERENCES ' . $prefix . 'lcache_events("event_id") ON DELETE CASCADE)'); - $this->dbh->exec('CREATE INDEX ' . $prefix . 'rewritten_entry ON ' . $prefix . 'lcache_tags ("event_id")'); - } - - public function testL1Factory() - { - // TODO: Move to L1CacheTest. - $staticL1 = $this->l1Factory()->create('static'); - $invalidL1 = $this->l1Factory()->create('invalid_cache_driver'); - $this->assertEquals(get_class($staticL1), get_class($invalidL1)); - } - - public function testClearStaticL2() - { - $l2 = new StaticL2(); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue'); - $l2->delete('mypool', new Address()); - $this->assertNull($l2->get($myaddr)); - } - - public function testStaticL2Expiration() - { - $l2 = new StaticL2(); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue', -1); - $this->assertNull($l2->get($myaddr)); - } - - public function testStaticL2Reread() - { - $l2 = new StaticL2(); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue'); - $this->assertEquals('myvalue', $l2->get($myaddr)); - $this->assertEquals('myvalue', $l2->get($myaddr)); - $this->assertEquals('myvalue', $l2->get($myaddr)); - $this->assertEquals('myvalue', $l2->get($myaddr)); - } - - public function testNewPoolSynchronization() - { - $central = new StaticL2(); - $pool1 = new Integrated($this->l1Factory()->create('static'), $central); - - $myaddr = new Address('mybin', 'mykey'); - - // Initialize sync for Pool 1. - $applied = $pool1->synchronize(); - $this->assertNull($applied); - $current_event_id = $pool1->getLastAppliedEventID(); - $this->assertEquals(0, $current_event_id); - - // Add a new entry to Pool 1. The last applied event should be our - // change. However, because the event is from the same pool, applied - // should be zero. - $pool1->set($myaddr, 'myvalue'); - $applied = $pool1->synchronize(); - $this->assertEquals(0, $applied); - $this->assertEquals($current_event_id + 1, $pool1->getLastAppliedEventID()); - - // Add a new pool. Sync should return NULL applied changes but should - // bump the last applied event ID. - $pool2 = new Integrated($this->l1Factory()->create('static'), $central); - $applied = $pool2->synchronize(); - $this->assertNull($applied); - $this->assertEquals($pool1->getLastAppliedEventID(), $pool2->getLastAppliedEventID()); - } - - protected function performTombstoneTest($l1) - { - // This test is not for L1 - this tests integratino logick. - $central = new Integrated($l1, new StaticL2()); - - $dne = new Address('mypool', 'mykey-dne'); - $this->assertNull($central->get($dne)); - - $tombstone = $central->getEntry($dne, true); - $this->assertNotNull($tombstone); - $this->assertNull($tombstone->value); - // The L1 should return the tombstone entry so the integrated cache - // can avoid rewriting it. - $tombstone = $l1->getEntry($dne); - $this->assertNotNull($tombstone); - $this->assertNull($tombstone->value); - - // The tombstone should also count as non-existence. - $this->assertFalse($central->exists($dne)); - - // This is a no-op for most L1 implementations, but it should not - // return false, regardless. - $this->assertTrue(false !== $l1->collectGarbage()); - } - - public function testStaticL1Tombstone() - { - $l1 = $this->l1Factory()->create('static'); - $this->performTombstoneTest($l1); - } - - public function testAPCuL1Tombstone() - { - $l1 = $this->l1Factory()->create('apcu', 'testAPCuL1Tombstone'); - $this->performTombstoneTest($l1); - } - - public function testSQLiteL1Tombstone() - { - $l1 = $this->l1Factory()->create('sqlite'); - $this->performTombstoneTest($l1); - } - - protected function performSynchronizationTest($central, $first_l1, $second_l1) - { - // Create two integrated pools with independent L1s. - $pool1 = new Integrated($first_l1, $central); - $pool2 = new Integrated($second_l1, $central); - - $myaddr = new Address('mybin', 'mykey'); - - // Set and get an entry in Pool 1. - $pool1->set($myaddr, 'myvalue'); - $this->assertEquals('myvalue', $pool1->get($myaddr)); - $this->assertEquals(1, $pool1->getHitsL1()); - $this->assertEquals(0, $pool1->getHitsL2()); - $this->assertEquals(0, $pool1->getMisses()); - - // Read the entry in Pool 2. - $this->assertEquals('myvalue', $pool2->get($myaddr)); - $this->assertEquals(0, $pool2->getHitsL1()); - $this->assertEquals(1, $pool2->getHitsL2()); - $this->assertEquals(0, $pool2->getMisses()); - - // Initialize Pool 2 synchronization. - $changes = $pool2->synchronize(); - $this->assertNull($changes); - $this->assertEquals(1, $second_l1->getLastAppliedEventID()); - - // Alter the item in Pool 1. Pool 2 should hit its L1 again - // with the out-of-date item. Synchronizing should fix it. - $pool1->set($myaddr, 'myvalue2'); - $this->assertEquals('myvalue', $pool2->get($myaddr)); - $applied = $pool2->synchronize(); - $this->assertEquals(1, $applied); - $this->assertEquals('myvalue2', $pool2->get($myaddr)); - - // Delete the item in Pool 1. Pool 2 should hit its L1 again - // with the now-deleted item. Synchronizing should fix it. - $pool1->delete($myaddr); - $this->assertEquals('myvalue2', $pool2->get($myaddr)); - $applied = $pool2->synchronize(); - $this->assertEquals(1, $applied); - $this->assertNull($pool2->get($myaddr)); - - // Try to get an entry that has never existed. - $myaddr_nonexistent = new Address('mybin', 'mykeynonexistent'); - $this->assertNull($pool1->get($myaddr_nonexistent)); - - // Test out bins and clearing. - $mybin1_mykey = new Address('mybin1', 'mykey'); - $mybin1 = new Address('mybin1'); - $mybin2_mykey = new Address('mybin2', 'mykey'); - $pool1->set($mybin1_mykey, 'myvalue1'); - $pool1->set($mybin2_mykey, 'myvalue2'); - $pool2->synchronize(); - $pool1->delete($mybin1); - - // The deleted bin should be evident in pool1 but not in pool2. - $this->assertNull($pool1->get($mybin1_mykey)); - $this->assertEquals('myvalue2', $pool1->get($mybin2_mykey)); - $this->assertEquals('myvalue1', $pool2->get($mybin1_mykey)); - $this->assertEquals('myvalue2', $pool2->get($mybin2_mykey)); - - // Synchronizing should propagate the bin clearing to pool2. - $pool2->synchronize(); - $this->assertNull($pool2->get($mybin1_mykey)); - $this->assertEquals('myvalue2', $pool2->get($mybin2_mykey)); - } - - protected function performClearSynchronizationTest($central, $first_l1, $second_l1) - { - // Create two integrated pools with independent L1s. - $pool1 = new Integrated($first_l1, $central); - $pool2 = new Integrated($second_l1, $central); - - $myaddr = new Address('mybin', 'mykey'); - - // Create an item, synchronize, and then do a complete clear. - $pool1->set($myaddr, 'mynewvalue'); - $this->assertEquals('mynewvalue', $pool1->get($myaddr)); - $pool2->synchronize(); - $this->assertEquals('mynewvalue', $pool2->get($myaddr)); - $pool1->delete(new Address()); - $this->assertNull($pool1->get($myaddr)); - - // Pool 2 should lag until it synchronizes. - $this->assertEquals('mynewvalue', $pool2->get($myaddr)); - $pool2->synchronize(); - $this->assertNull($pool2->get($myaddr)); - } - - protected function performTaggedSynchronizationTest($central, $first_l1, $second_l1) - { - // Create two integrated pools with independent L1s. - $pool1 = new Integrated($first_l1, $central); - $pool2 = new Integrated($second_l1, $central); - - $myaddr = new Address('mybin', 'mykey'); - - // Test deleting a tag that doesn't exist yet. - $pool1->deleteTag('mytag'); - - // Set and get an entry in Pool 1. - $pool1->set($myaddr, 'myvalue', null, ['mytag']); - $this->assertEquals([$myaddr], $central->getAddressesForTag('mytag')); - $this->assertEquals('myvalue', $pool1->get($myaddr)); - $this->assertEquals(1, $pool1->getHitsL1()); - $this->assertEquals(0, $pool1->getHitsL2()); - $this->assertEquals(0, $pool1->getMisses()); - - // Read the entry in Pool 2. - $this->assertEquals('myvalue', $pool2->get($myaddr)); - $this->assertEquals(0, $pool2->getHitsL1()); - $this->assertEquals(1, $pool2->getHitsL2()); - $this->assertEquals(0, $pool2->getMisses()); - - - // Initialize Pool 2 synchronization. - $pool2->synchronize(); - - // Delete the tag. The item should now be missing from Pool 1. - $pool1->deleteTag('mytag'); // TKTK - $this->assertNull($central->get($myaddr)); - $this->assertNull($first_l1->get($myaddr)); - $this->assertNull($pool1->get($myaddr)); - - - // Pool 2 should hit its L1 again with the tag-deleted item. - // Synchronizing should fix it. - $this->assertEquals('myvalue', $pool2->get($myaddr)); - $applied = $pool2->synchronize(); - $this->assertEquals(1, $applied); - $this->assertNull($pool2->get($myaddr)); - - // Ensure the addition of a second tag still works for deletion. - $myaddr2 = new Address('mybin', 'mykey2'); - $pool1->set($myaddr2, 'myvalue', null, ['mytag']); - $pool1->set($myaddr2, 'myvalue', null, ['mytag', 'mytag2']); - $pool1->deleteTag('mytag2'); - $this->assertNull($pool1->get($myaddr2)); - - // Ensure updating a second item with a tag doesn't remove it from the - // first. - $pool1->delete(new Address()); - $pool1->set($myaddr, 'myvalue', null, ['mytag', 'mytag2']); - $pool1->set($myaddr2, 'myvalue', null, ['mytag', 'mytag2']); - $pool1->set($myaddr, 'myvalue', null, ['mytag']); - - $found_addresses = $central->getAddressesForTag('mytag2'); - // getAddressesForTag() may return additional addresses, but it should - // always return at least the current tagged address. - $found = false; - foreach ($found_addresses as $found_address) { - if ($found_address->serialize() === $myaddr2->serialize()) { - $found = true; - } - } - $this->assertTrue($found); - } - - public function testSynchronizationStatic() - { - $central = new StaticL2(); - $this->performSynchronizationTest($central, $this->l1Factory()->create('static'), $this->l1Factory()->create('static')); - } - - public function testTaggedSynchronizationStatic() - { - $central = new StaticL2(); - $this->performTaggedSynchronizationTest($central, $this->l1Factory()->create('static'), $this->l1Factory()->create('static')); - } - - public function testSynchronizationAPCu() - { - // Warning: As long as LCache\APCuL1 flushes all of APCu on a wildcard - // deletion, it is not possible to test such functionality in a - // single process. - - $run_test = false; - if (function_exists('apcu_store')) { - apcu_store('test_key', 'test_value'); - $value = apcu_fetch('test_key'); - if ($value === 'test_value') { - $run_test = true; - } - } - - if ($run_test) { - $central = new StaticL2(); - $this->performSynchronizationTest( - $central, - $this->l1Factory()->create('apcu', 'testSynchronizationAPCu1'), - $this->l1Factory()->create('apcu', 'testSynchronizationAPCu2') - ); - - // Because of how APCu only offers full cache clears, we test against a static cache for the other L1. - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('apcu', 'testSynchronizationAPCu1b'), - $this->l1Factory()->create('static') - ); - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('static'), - $this->l1Factory()->create('apcu', 'testSynchronizationAPCu1c') - ); - } else { - $this->markTestSkipped('The APCu extension is not installed, enabled (for the CLI), or functional.'); - } - } - - public function testSynchronizationSQLiteL1() - { - $central = new StaticL2(); - $this->performSynchronizationTest( - $central, - $this->l1Factory()->create('sqlite'), - $this->l1Factory()->create('sqlite') - ); - - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('sqlite'), - $this->l1Factory()->create('static') - ); - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('static'), - $this->l1Factory()->create('sqlite') - ); - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('sqlite'), - $this->l1Factory()->create('sqlite') - ); - } - - public function testSynchronizationDatabase() - { - $this->createSchema(); - $central = new DatabaseL2($this->dbh); - $this->performSynchronizationTest( - $central, - $this->l1Factory()->create('static', 'testSynchronizationDatabase1'), - $this->l1Factory()->create('static', 'testSynchronizationDatabase2') - ); - $this->performClearSynchronizationTest( - $central, - $this->l1Factory()->create('static', 'testSynchronizationDatabase1a'), - $this->l1Factory()->create('static', 'testSynchronizationDatabase2a') - ); - } - - public function testTaggedSynchronizationDatabase() - { - $this->createSchema(); - $central = new DatabaseL2($this->dbh); - $this->performTaggedSynchronizationTest( - $central, - $this->l1Factory()->create('static', 'testTaggedSynchronizationDatabase1'), - $this->l1Factory()->create('static', 'testTaggedSynchronizationDatabase2') - ); - } - - public function testBrokenDatabaseFallback() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh, '', true); - $l1 = $this->l1Factory()->create('static', 'first'); - $pool = new Integrated($l1, $l2); - - $myaddr = new Address('mybin', 'mykey'); - - // Break the schema and try operations. - $this->dbh->exec('DROP TABLE lcache_tags'); - $this->assertNull($pool->set($myaddr, 'myvalue', null, ['mytag'])); - $this->assertGreaterThanOREqual(1, count($l2->getErrors())); - $this->assertNull($pool->deleteTag('mytag')); - $pool->synchronize(); - - $myaddr2 = new Address('mybin', 'mykey2'); - - $this->dbh->exec('DROP TABLE lcache_events'); - $this->assertNull($pool->synchronize()); - $this->assertNull($pool->get($myaddr2)); - $this->assertNull($pool->exists($myaddr2)); - $this->assertNull($pool->set($myaddr, 'myvalue')); - $this->assertNull($pool->delete($myaddr)); - $this->assertNull($pool->delete(new Address())); - $this->assertNull($l2->getAddressesForTag('mytag')); - - // Try applying events to an uninitialized L1. - $this->assertNull($l2->applyEvents($this->l1Factory()->create('static'))); - - // Try garbage collection routines. - $pool->collectGarbage(); - $count = $l2->countGarbage(); - $this->assertNull($count); - } - - public function testDatabaseL2SyncWithNoWrites() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh, '', true); - $l1 = $this->l1Factory()->create('static', 'first'); - $pool = new Integrated($l1, $l2); - $pool->synchronize(); - } - - public function testExistsDatabaseL2() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue'); - $this->assertTrue($l2->exists($myaddr)); - $l2->delete('mypool', $myaddr); - $this->assertFalse($l2->exists($myaddr)); - } - - public function testEmptyCleanUpDatabaseL2() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - } - - public function testExistsIntegrated() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $l1 = $this->l1Factory()->create('apcu', 'first'); - $pool = new Integrated($l1, $l2); - $myaddr = new Address('mybin', 'mykey'); - $pool->set($myaddr, 'myvalue'); - $this->assertTrue($pool->exists($myaddr)); - $pool->delete($myaddr); - $this->assertFalse($pool->exists($myaddr)); - } - - public function testDatabaseL2Prefix() - { - $this->createSchema('myprefix_'); - $l2 = new DatabaseL2($this->dbh, 'myprefix_'); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue', null, ['mytag']); - $this->assertEquals('myvalue', $l2->get($myaddr)); - } - - public function testPoolIntegrated() - { - $l2 = new StaticL2(); - $l1 = $this->l1Factory()->create('apcu', 'first'); - $pool = new Integrated($l1, $l2); - $this->assertEquals('first', $pool->getPool()); - } - - protected function performFailedUnserializationTest($l2) - { - $l1 = $this->l1Factory()->create('static'); - $pool = new Integrated($l1, $l2); - $myaddr = new Address('mybin', 'mykey'); - - $invalid_object = 'O:10:"HelloWorl":0:{}'; - - // Set the L1's high water mark. - $pool->set($myaddr, 'valid'); - $changes = $pool->synchronize(); - $this->assertNull($changes); // Just initialized event high water mark. - $this->assertEquals(1, $l1->getLastAppliedEventID()); - - // Put an invalid object into the L2 and synchronize again. - $l2->set('anotherpool', $myaddr, $invalid_object, null, [], true); - $changes = $pool->synchronize(); - $this->assertEquals(1, $changes); - $this->assertEquals(2, $l1->getLastAppliedEventID()); - - // The sync should delete the item from the L1, causing it to miss. - $this->assertNull($l1->get($myaddr)); - $this->assertEquals(0, $l1->getHits()); - $this->assertEquals(1, $l1->getMisses()); - } - - protected function performCaughtUnserializationOnGetTest($l2) - { - $l1 = $this->l1Factory()->create('static'); - $pool = new Integrated($l1, $l2); - $invalid_object = 'O:10:"HelloWorl":0:{}'; - $myaddr = new Address('mybin', 'performCaughtUnserializationOnGetTest'); - $l2->set('anypool', $myaddr, $invalid_object, null, [], true); - try { - $pool->get($myaddr); - $this->assertTrue(false); // Should not reach here. - } catch (UnserializationException $e) { - $this->assertEquals($invalid_object, $e->getSerializedData()); - - // The text of the exception should include the class name, bin, and key. - $this->assertRegExp('/^' . preg_quote('LCache\UnserializationException: Cache') . '/', strval($e)); - $this->assertRegExp('/bin "' . preg_quote($myaddr->getBin()) . '"/', strval($e)); - $this->assertRegExp('/key "' . preg_quote($myaddr->getKey()) . '"/', strval($e)); - } - } - - public function testDatabaseL2FailedUnserialization() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $this->performFailedUnserializationTest($l2); - $this->performCaughtUnserializationOnGetTest($l2); - } - - public function testStaticL2FailedUnserialization() - { - $l2 = new StaticL2(); - $this->performFailedUnserializationTest($l2); - $this->performCaughtUnserializationOnGetTest($l2); - } - - // Callers should expect an UnserializationException. - protected function performFailedUnserializationOnGetTest($l2) - { - $l1 = $this->l1Factory()->create('static'); - $pool = new Integrated($l1, $l2); - $invalid_object = 'O:10:"HelloWorl":0:{}'; - $myaddr = new Address('mybin', 'performFailedUnserializationOnGetTest'); - $l2->set('anypool', $myaddr, $invalid_object, null, [], true); - $pool->get($myaddr); - } - - /** - * @expectedException LCache\UnserializationException - */ - public function testDatabaseL2FailedUnserializationOnGet() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $this->performFailedUnserializationOnGetTest($l2); - } - - /** - * @expectedException LCache\UnserializationException - */ - public function testStaticL2FailedUnserializationOnGet() - { - $l2 = new StaticL2(); - $this->performFailedUnserializationOnGetTest($l2); - } - - public function performGarbageCollectionTest($l2) - { - $pool = new Integrated($this->l1Factory()->create('static'), $l2); - $myaddr = new Address('mybin', 'mykey'); - $this->assertEquals(0, $l2->countGarbage()); - $pool->set($myaddr, 'myvalue', -1); - $this->assertEquals(1, $l2->countGarbage()); - $pool->collectGarbage(); - $this->assertEquals(0, $l2->countGarbage()); - } - - public function testDatabaseL2GarbageCollection() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $this->performGarbageCollectionTest($l2); - } - - public function testStaticL2GarbageCollection() - { - $l2 = new StaticL2(); - $this->performGarbageCollectionTest($l2); - - // Test item limits. - $pool = new Integrated($this->l1Factory()->create('static'), $l2); - $myaddr2 = new Address('mybin', 'mykey2'); - $myaddr3 = new Address('mybin', 'mykey3'); - $pool->collectGarbage(); - $pool->set($myaddr2, 'myvalue', -1); - $pool->set($myaddr3, 'myvalue', -1); - $this->assertEquals(2, $l2->countGarbage()); - $pool->collectGarbage(1); - $this->assertEquals(1, $l2->countGarbage()); - } - - /** - * @todo Is this still needed, or it can be deleted. - * Same tests are implemented against all L1 drivers directly in - * L1CacheTest::testStateStorage(). - */ - protected function performHitSetCounterTest($l1) - { - $pool = new Integrated($l1, new StaticL2()); - $myaddr = new Address('mybin', 'mykey'); - - $this->assertEquals(0, $l1->getKeyOverhead($myaddr)); - $pool->set($myaddr, 'myvalue'); - $this->assertEquals(1, $l1->getKeyOverhead($myaddr)); - $pool->get($myaddr); - $this->assertEquals(0, $l1->getKeyOverhead($myaddr)); - $pool->set($myaddr, 'myvalue2'); - $this->assertEquals(1, $l1->getKeyOverhead($myaddr)); - - // An unknown get should create negative overhead, generally - // in anticipation of a set. - $myaddr2 = new Address('mybin', 'mykey2'); - $pool->get($myaddr2); - $this->assertEquals(-1, $l1->getKeyOverhead($myaddr2)); - } - - public function testStaticL1Counters() - { - $this->performHitSetCounterTest($this->l1Factory()->create('static')); - } - - public function testAPCuL1Counters() - { - $this->performHitSetCounterTest($this->l1Factory()->create('apcu', 'counters')); - } - - public function testSQLiteL1Counters() - { - $this->performHitSetCounterTest($this->l1Factory()->create('sqlite')); - } - - protected function performExcessiveOverheadSkippingTest($l1) - { - $pool = new Integrated($l1, new StaticL2(), 2); - $myaddr = new Address('mybin', 'mykey'); - - // These should go through entirely. - $this->assertNotNull($pool->set($myaddr, 'myvalue1')); - $this->assertNotNull($pool->set($myaddr, 'myvalue2')); - - // This should return an event_id but delete the item. - $this->assertEquals(2, $l1->getKeyOverhead($myaddr)); - $this->assertFalse($l1->isNegativeCache($myaddr)); - $this->assertNotNull($pool->set($myaddr, 'myvalue3')); - $this->assertFalse($pool->exists($myaddr)); - - // A few more sets to offset the existence check, which some L1s may - // treat as a hit. This should put us firmly in excessive territory. - $pool->set($myaddr, 'myvalue4'); - $pool->set($myaddr, 'myvalue5'); - $pool->set($myaddr, 'myvalue6'); - - // Now, with the local negative cache, these shouldn't even return - // an event_id. - $this->assertNull($pool->set($myaddr, 'myvalueA1')); - $this->assertNull($pool->set($myaddr, 'myvalueA2')); - - // Test a lot of sets but with enough hits to drop below the threshold. - $myaddr2 = new Address('mybin', 'mykey2'); - $this->assertNotNull($pool->set($myaddr2, 'myvalue')); - $this->assertNotNull($pool->set($myaddr2, 'myvalue')); - $this->assertEquals('myvalue', $pool->get($myaddr2)); - $this->assertEquals('myvalue', $pool->get($myaddr2)); - $this->assertNotNull($pool->set($myaddr2, 'myvalue')); - $this->assertNotNull($pool->set($myaddr2, 'myvalue')); - } - - public function testStaticL1ExcessiveOverheadSkipping() - { - $this->performExcessiveOverheadSkippingTest($this->l1Factory()->create('static')); - } - - public function testAPCuL1ExcessiveOverheadSkipping() - { - $this->performExcessiveOverheadSkippingTest($this->l1Factory()->create('apcu', 'overhead')); - } - - public function testSQLiteL1ExcessiveOverheadSkipping() - { - $this->performExcessiveOverheadSkippingTest($this->l1Factory()->create('sqlite')); - } - - public function testAPCuL1IntegratedExpiration() - { - $l1 = $this->l1Factory()->create('apcu', 'expiration'); - $this->performIntegratedExpiration($l1); - } - - public function testStaticL1IntegratedExpiration() - { - $l1 = $this->l1Factory()->create('static'); - $this->performIntegratedExpiration($l1); - } - - public function testSQLiteL1IntegratedExpiration() - { - $l1 = $this->l1Factory()->create('sqlite'); - $this->performIntegratedExpiration($l1); - } - - public function performIntegratedExpiration($l1) - { - - $pool = new Integrated($l1, new StaticL2()); - $myaddr = new Address('mybin', 'mykey'); - $pool->set($myaddr, 'value', 1); - $this->assertEquals('value', $pool->get($myaddr)); - $this->assertEquals($_SERVER['REQUEST_TIME'] + 1, $l1->getEntry($myaddr)->expiration); - - // Setting items with past expirations should result in a nothing stored. - $myaddr2 = new Address('mybin', 'mykey2'); - $l1->set(0, $myaddr2, 'value', $_SERVER['REQUEST_TIME'] - 1); - $this->assertNull($l1->get($myaddr2)); - - // Setting an TTL/expiration more than request time should be treated - // as an expiration. - $pool->set($myaddr, 'value', $_SERVER['REQUEST_TIME'] + 1); - $this->assertEquals('value', $pool->get($myaddr)); - $this->assertEquals($_SERVER['REQUEST_TIME'] + 1, $l1->getEntry($myaddr)->expiration); - } - - public function testDatabaseL2BatchDeletion() - { - $this->createSchema(); - $l2 = new DatabaseL2($this->dbh); - $myaddr = new Address('mybin', 'mykey'); - $l2->set('mypool', $myaddr, 'myvalue'); - - $mybin = new Address('mybin', null); - $l2->delete('mypool', $mybin); - - $this->assertNull($l2->get($myaddr)); - } - - public function testDatabaseL2CleanupAfterWrite() - { - $this->createSchema(); - $myaddr = new Address('mybin', 'mykey'); - - // Write to the key with the first client. - $l2_client_a = new DatabaseL2($this->dbh); - $event_id_a = $l2_client_a->set('mypool', $myaddr, 'myvalue'); - - // Verify that the first event exists and has the right value. - $event = $l2_client_a->getEvent($event_id_a); - $this->assertEquals('myvalue', $event->value); - - // Use a second client. This gives us a fresh event_id_low_water, - // just like a new PHP request. - $l2_client_b = new DatabaseL2($this->dbh); - - // Write to the same key with the second client. - $event_id_b = $l2_client_b->set('mypool', $myaddr, 'myvalue2'); - - // Verify that the second event exists and has the right value. - $event = $l2_client_b->getEvent($event_id_b); - $this->assertEquals('myvalue2', $event->value); - - // Call the same method as on destruction. This second client should - // now prune any writes to the key from earlier requests. - $l2_client_b->pruneReplacedEvents(); - - // Verify that the first event no longer exists. - $event = $l2_client_b->getEvent($event_id_a); - $this->assertNull($event); - } - - /** - * @return PHPUnit_Extensions_Database_DataSet_IDataSet - */ - protected function getDataSet() - { - return new \PHPUnit_Extensions_Database_DataSet_DefaultDataSet(); - } -} diff --git a/tests/Pool/DefaultTest.php b/tests/Pool/DefaultTest.php new file mode 100644 index 0000000..42f4ab7 --- /dev/null +++ b/tests/Pool/DefaultTest.php @@ -0,0 +1,25 @@ +driverName(); + $pool = "pool-" . uniqid('', true) . '-' . mt_rand(); + return (new StateL1Factory())->create($driver, $pool); + } + + /** + * @group state + */ + public function testL1StateFactory() + { + $staticL1 = $this->getInstance('static'); + $invalidL1 = $this->getInstance('invalid_cache_driver'); + $this->assertEquals(get_class($staticL1), get_class($invalidL1)); + } + + /** + * @group state + */ + public function testCreation() + { + $state = $this->getInstance(); + $this->assertTrue($state instanceof StateL1Interface); + $this->assertEquals(0, $state->getHits()); + $this->assertEquals(0, $state->getMisses()); + $this->assertNull($state->getLastAppliedEventID()); + } + + /** + * @group state + */ + public function testHitMissClear() + { + $state = $this->getInstance(); + + // Hits. + $this->assertTrue($state->recordHit()); + $this->assertEquals(1, $state->getHits()); + + // Miss. + $this->assertTrue($state->recordMiss()); + $this->assertEquals(1, $state->getMisses()); + + // Clear them + $this->assertTrue($state->clear()); + $this->assertEquals(0, $state->getHits()); + $this->assertEquals(0, $state->getMisses()); + } + + /** + * @group state + */ + public function testSettingEventId() + { + $state = $this->getInstance(); + + // No changes when invalid input (smaller). + $this->assertFalse($state->setLastAppliedEventID(-1)); + $this->assertNull($state->getLastAppliedEventID()); + + // Allows init with zero. + $this->assertTrue($state->setLastAppliedEventID(0)); + $this->assertEquals(0, $state->getLastAppliedEventID()); + + // Allows to set newer events. + $this->assertTrue($state->setLastAppliedEventID(2)); + $this->assertEquals(2, $state->getLastAppliedEventID()); + + // Allows setting same events. + $this->assertTrue($state->setLastAppliedEventID(2)); + $this->assertEquals(2, $state->getLastAppliedEventID()); + + // Does not allow for setting older events in. + $this->assertFalse($state->setLastAppliedEventID(1)); + $this->assertEquals(2, $state->getLastAppliedEventID()); + } +} diff --git a/tests/Utils/IntegratedMock.php b/tests/Utils/IntegratedMock.php new file mode 100644 index 0000000..4aac0a0 --- /dev/null +++ b/tests/Utils/IntegratedMock.php @@ -0,0 +1,76 @@ +l1; + } + + /** + * Accessor needed for tests. + * + * @return L2 + */ + public function getL2() + { + return $this->l2; + } + + /** + * Utility accessor. + * + * @return int + */ + public function getMissesL1() + { + return $this->l1->getMisses(); + } + + /** + * Utility accessor. + * + * @return int + */ + public function getMissesL2() + { + return $this->getMisses(); + } + + public function collectGarbageL1($item_limit = null) + { + return $this->l1->collectGarbage($item_limit); + } + + public function collectGarbageL2($item_limit = null) + { + return $this->collectGarbage($item_limit); + } +} diff --git a/tests/Utils/LCacheDBTestTrait.php b/tests/Utils/LCacheDBTestTrait.php new file mode 100644 index 0000000..5c27040 --- /dev/null +++ b/tests/Utils/LCacheDBTestTrait.php @@ -0,0 +1,100 @@ +dbh = new \PDO('sqlite::memory:'); + $this->dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $connection = $this->createDefaultDBConnection($this->dbh, ':memory:'); + return $connection; + } + + /** + * Needed by PHPUnit_Extensions_Database_TestCase_Trait. + * + * @return PHPUnit_Extensions_Database_DataSet_IDataSet + */ + protected function getDataSet() + { + return new \PHPUnit_Extensions_Database_DataSet_DefaultDataSet(); + } + + /** + * Utility executed before every test. + */ + protected function setUp() + { + $this->phpUnitDbTraitSetUp(); + $this->tablesCreated = false; + $this->dbPrefix = ''; + $this->dbErrorsLog = false; + } + + /** + * Utility to create the chema on a given connection. + * + * @param string $prefix + */ + protected function createSchema($prefix = '') + { + if ($this->tablesCreated) { + return; + } + $this->dbh->exec('PRAGMA foreign_keys = ON'); + + $this->dbh->exec('CREATE TABLE ' . $prefix . 'lcache_events (' + . '"event_id" INTEGER PRIMARY KEY AUTOINCREMENT, ' + . '"pool" TEXT NOT NULL, ' + . '"address" TEXT, ' + . '"value" BLOB, ' + . '"expiration" INTEGER, ' + . '"created" INTEGER NOT NULL)'); + $this->dbh->exec('CREATE INDEX ' . $prefix . 'latest_entry ON ' . $prefix . 'lcache_events ("address", "event_id")'); + + // @TODO: Set a proper primary key and foreign key relationship. + $this->dbh->exec('CREATE TABLE ' . $prefix . 'lcache_tags (' + . '"tag" TEXT, ' + . '"event_id" INTEGER, ' + . 'PRIMARY KEY ("tag", "event_id"), ' + . 'FOREIGN KEY("event_id") REFERENCES ' . $prefix . 'lcache_events("event_id") ON DELETE CASCADE)'); + $this->dbh->exec('CREATE INDEX ' . $prefix . 'rewritten_entry ON ' . $prefix . 'lcache_tags ("event_id")'); + + $this->tablesCreated = true; + } +}