Skip to content

Commit

Permalink
feature #27031 [Cache] Use sub-second accuracy for internal expiry ca…
Browse files Browse the repository at this point in the history
…lculations (nicolas-grekas)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[Cache] Use sub-second accuracy for internal expiry calculations

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | not really
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

Embeds #26929, #27009 and #27028, let's focus on the 4th commit for now.

This is my last significant PR in the Cache series :)

By using integer expiries internally, our current implementations are sensitive to abrupt transitions when time() goes to next second: `$s = time(); sleep(1); echo time() - $s;` *can* display 2 from time to time.
This means that we do expire items earlier than required by the expiration settings on items.
This also means that there is no way to have a sub-second expiry. For remote backends, that's fine, but for ArrayAdapter, that's a limitation we can remove.

This PR replaces calls to `time()` by `microtime(true)`, providing more accurate timing measurements internally.

Commits
-------

08554ea [Cache] Use sub-second accuracy for internal expiry calculations
  • Loading branch information
fabpot committed Jun 11, 2018
2 parents 169b13c + 08554ea commit 205d161
Show file tree
Hide file tree
Showing 9 changed files with 22 additions and 23 deletions.
8 changes: 3 additions & 5 deletions src/Symfony/Component/Cache/Adapter/AbstractAdapter.php
Expand Up @@ -68,15 +68,13 @@ function ($key, $value, $isHit) use ($defaultLifetime) {
$this->mergeByLifetime = \Closure::bind(
function ($deferred, $namespace, &$expiredIds) use ($getId) {
$byLifetime = array();
$now = time();
$now = microtime(true);
$expiredIds = array();

foreach ($deferred as $key => $item) {
if (null === $item->expiry) {
$ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0;
} elseif ($item->expiry > $now) {
$ttl = $item->expiry - $now;
} else {
} elseif (0 >= $ttl = (int) ($item->expiry - $now)) {
$expiredIds[] = $getId($key);
continue;
}
Expand Down Expand Up @@ -107,7 +105,7 @@ function ($deferred, $namespace, &$expiredIds) use ($getId) {
*/
public static function createSystemCache($namespace, $defaultLifetime, $version, $directory, LoggerInterface $logger = null)
{
@trigger_error(sprintf('The "%s()" method is deprecated since Symfony 4.2.', __CLASS__), E_USER_DEPRECATED);
@trigger_error(sprintf('The "%s()" method is deprecated since Symfony 4.2.', __METHOD__), E_USER_DEPRECATED);

if (null === self::$apcuSupported) {
self::$apcuSupported = ApcuAdapter::isSupported();
Expand Down
6 changes: 3 additions & 3 deletions src/Symfony/Component/Cache/Adapter/ArrayAdapter.php
Expand Up @@ -87,7 +87,7 @@ public function getItems(array $keys = array())
CacheItem::validateKey($key);
}

return $this->generateItems($keys, time(), $this->createCacheItem);
return $this->generateItems($keys, microtime(true), $this->createCacheItem);
}

/**
Expand Down Expand Up @@ -115,7 +115,7 @@ public function save(CacheItemInterface $item)
$value = $item["\0*\0value"];
$expiry = $item["\0*\0expiry"];

if (null !== $expiry && $expiry <= time()) {
if (null !== $expiry && $expiry <= microtime(true)) {
$this->deleteItem($key);

return true;
Expand All @@ -131,7 +131,7 @@ public function save(CacheItemInterface $item)
}
}
if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) {
$expiry = time() + $item["\0*\0defaultLifetime"];
$expiry = microtime(true) + $item["\0*\0defaultLifetime"];
}

$this->values[$key] = $value;
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Adapter/ProxyAdapter.php
Expand Up @@ -81,7 +81,7 @@ function (CacheItemInterface $innerItem, array $item) {
$item["\0*\0value"] = array("\x9D".pack('VN', (int) $metadata[CacheItem::METADATA_EXPIRY] - CacheItem::METADATA_EXPIRY_OFFSET, $metadata[CacheItem::METADATA_CTIME])."\x5F" => $item["\0*\0value"]);
}
$innerItem->set($item["\0*\0value"]);
$innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U', $item["\0*\0expiry"]) : null);
$innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U.u', sprintf('%.6f', $item["\0*\0expiry"])) : null);
},
null,
CacheItem::class
Expand Down Expand Up @@ -200,7 +200,7 @@ private function doSave(CacheItemInterface $item, $method)
}
$item = (array) $item;
if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) {
$item["\0*\0expiry"] = time() + $item["\0*\0defaultLifetime"];
$item["\0*\0expiry"] = microtime(true) + $item["\0*\0defaultLifetime"];
}
$innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]);
($this->setInnerItem)($innerItem, $item);
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Cache/CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
-----

* added `CacheInterface`, which provides stampede protection via probabilistic early expiration and should become the preferred way to use a cache
* added sub-second expiry accuracy for backends that support it
* throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool
* deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead
* deprecated the `AbstractAdapter::createSystemCache()` method
Expand Down
10 changes: 5 additions & 5 deletions src/Symfony/Component/Cache/CacheItem.php
Expand Up @@ -89,9 +89,9 @@ public function set($value)
public function expiresAt($expiration)
{
if (null === $expiration) {
$this->expiry = $this->defaultLifetime > 0 ? time() + $this->defaultLifetime : null;
$this->expiry = $this->defaultLifetime > 0 ? microtime(true) + $this->defaultLifetime : null;
} elseif ($expiration instanceof \DateTimeInterface) {
$this->expiry = (int) $expiration->format('U');
$this->expiry = (float) $expiration->format('U.u');
} else {
throw new InvalidArgumentException(sprintf('Expiration date must implement DateTimeInterface or be null, "%s" given', is_object($expiration) ? get_class($expiration) : gettype($expiration)));
}
Expand All @@ -105,11 +105,11 @@ public function expiresAt($expiration)
public function expiresAfter($time)
{
if (null === $time) {
$this->expiry = $this->defaultLifetime > 0 ? time() + $this->defaultLifetime : null;
$this->expiry = $this->defaultLifetime > 0 ? microtime(true) + $this->defaultLifetime : null;
} elseif ($time instanceof \DateInterval) {
$this->expiry = (int) \DateTime::createFromFormat('U', time())->add($time)->format('U');
$this->expiry = microtime(true) + \DateTime::createFromFormat('U', 0)->add($time)->format('U.u');
} elseif (\is_int($time)) {
$this->expiry = $time + time();
$this->expiry = $time + microtime(true);
} else {
throw new InvalidArgumentException(sprintf('Expiration date must be an integer, a DateInterval or null, "%s" given', is_object($time) ? get_class($time) : gettype($time)));
}
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Simple/ArrayCache.php
Expand Up @@ -64,7 +64,7 @@ public function getMultiple($keys, $default = null)
CacheItem::validateKey($key);
}

return $this->generateItems($keys, time(), function ($k, $v, $hit) use ($default) { return $hit ? $v : $default; });
return $this->generateItems($keys, microtime(true), function ($k, $v, $hit) use ($default) { return $hit ? $v : $default; });
}

/**
Expand Down Expand Up @@ -121,7 +121,7 @@ public function setMultiple($values, $ttl = null)
}
}
}
$expiry = 0 < $ttl ? time() + $ttl : PHP_INT_MAX;
$expiry = 0 < $ttl ? microtime(true) + $ttl : PHP_INT_MAX;

foreach ($valuesArray as $key => $value) {
$this->values[$key] = $value;
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php
Expand Up @@ -77,10 +77,10 @@ public function testGetMetadata()
$item = $cache->getItem('foo');

$expected = array(
CacheItem::METADATA_EXPIRY => 9 + time(),
CacheItem::METADATA_EXPIRY => 9.5 + time(),
CacheItem::METADATA_CTIME => 1000,
);
$this->assertSame($expected, $item->getMetadata());
$this->assertEquals($expected, $item->getMetadata(), 'Item metadata should embed expiry and ctime.', .6);
}

public function testDefaultLifeTime()
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Tests/Fixtures/ArrayCache.php
Expand Up @@ -21,12 +21,12 @@ protected function doContains($id)

$expiry = $this->data[$id][1];

return !$expiry || time() <= $expiry || !$this->doDelete($id);
return !$expiry || microtime(true) < $expiry || !$this->doDelete($id);
}

protected function doSave($id, $data, $lifeTime = 0)
{
$this->data[$id] = array($data, $lifeTime ? time() + $lifeTime : false);
$this->data[$id] = array($data, $lifeTime ? microtime(true) + $lifeTime : false);

return true;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Traits/ArrayTrait.php
Expand Up @@ -44,7 +44,7 @@ public function hasItem($key)
{
CacheItem::validateKey($key);

return isset($this->expiries[$key]) && ($this->expiries[$key] >= time() || !$this->deleteItem($key));
return isset($this->expiries[$key]) && ($this->expiries[$key] > microtime(true) || !$this->deleteItem($key));
}

/**
Expand Down Expand Up @@ -81,7 +81,7 @@ private function generateItems(array $keys, $now, $f)
{
foreach ($keys as $i => $key) {
try {
if (!$isHit = isset($this->expiries[$key]) && ($this->expiries[$key] >= $now || !$this->deleteItem($key))) {
if (!$isHit = isset($this->expiries[$key]) && ($this->expiries[$key] > $now || !$this->deleteItem($key))) {
$this->values[$key] = $value = null;
} elseif (!$this->storeSerialized) {
$value = $this->values[$key];
Expand Down

0 comments on commit 205d161

Please sign in to comment.