* );
* @endcode
*
+ * Example usage (key that is expensive with too many DB dependencies for "check keys"):
+ * @code
+ * $catToys = $cache->getWithSetCallback(
+ * // Key to store the cached value under
+ * $cache->makeKey( 'cat-toys', $catId ),
+ * // Time-to-live (seconds)
+ * $cache::TTL_HOUR,
+ * // Function that derives the new key value
+ * function ( $oldValue, &$ttl, array &$setOpts ) {
+ * // Determine new value from the DB
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * return CatToys::newFromResults( $dbr->select( ... ) );
+ * },
+ * [
+ * // Get the highest timestamp of any of the cat's toys
+ * 'touchedCallback' => function ( $value ) use ( $catId ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * $ts = $dbr->selectField( 'cat_toys', 'MAX(ct_touched)', ... );
+ *
+ * return wfTimestampOrNull( TS_UNIX, $ts );
+ * },
+ * // Avoid DB queries for repeated access
+ * 'pcTTL' => $cache::TTL_PROC_SHORT
+ * ]
+ * );
+ * @endcode
+ *
* Example usage (hot key holding most recent 100 events):
* @code
* $lastCatActions = $cache->getWithSetCallback(
* expired for this specified time. This is useful if adaptiveTTL() is used on the old
* value's as-of time when it is verified as still being correct.
* Default: WANObjectCache::STALE_TTL_NONE
+ * - touchedCallback: A callback that takes the current value and returns a UNIX timestamp
+ * indicating the last time a dynamic dependency changed. Null can be returned if there
+ * are no relevant dependency changes to check. This can be used to check against things
+ * like last-modified times of files or DB timestamp fields. This should generally not be
+ * used for small and easily queried values in a DB if the callback itself ends up doing
+ * a similarly expensive DB query to check a timestamp. Usages of this option makes the
+ * most sense for values that are moderately to highly expensive to regenerate and easy
+ * to query for dependency timestamps. The use of "pcTTL" reduces timestamp queries.
+ * Default: null.
* @return mixed Value found or written to the key
* @note Options added in 1.28: version, busyValue, hotTTR, ageNew, pcGroup, minAsOf
* @note Options added in 1.31: staleTTL, graceTTL
+ * @note Options added in 1.33: touchedCallback
* @note Callable type hints are not used to avoid class-autoloading
*/
final public function getWithSetCallback( $key, $ttl, $callback, array $opts = [] ) {
$ageNew = $opts['ageNew'] ?? self::AGE_NEW;
$minTime = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
$versioned = isset( $opts['version'] );
+ $touchedCallback = $opts['touchedCallback'] ?? null;
// Get a collection name to describe this class of key
$kClass = $this->determineKeyClass( $key );
$cValue = $this->get( $key, $curTTL, $checkKeys, $asOf ); // current value
$value = $cValue; // return value
+ // Apply additional dynamic expiration logic if supplied
+ $curTTL = $this->applyTouchedCallback( $value, $asOf, $curTTL, $touchedCallback );
+
$preCallbackTime = $this->getCurrentTime();
// Determine if a cached value regeneration is needed or desired
- if ( $value !== false
- && $this->isAliveOrInGracePeriod( $curTTL, $graceTTL )
- && $this->isValid( $value, $versioned, $asOf, $minTime )
+ if (
+ $this->isValid( $value, $versioned, $asOf, $minTime ) &&
+ $this->isAliveOrInGracePeriod( $curTTL, $graceTTL )
) {
$preemptiveRefresh = (
$this->worthRefreshExpiring( $curTTL, $lowTTL ) ||
}
}
- // A deleted key with a negative TTL left must be tombstoned
- $isTombstone = ( $curTTL !== null && $value === false );
- if ( $isTombstone && $lockTSE <= 0 ) {
- // Use the INTERIM value for tombstoned keys to reduce regeneration load
- $lockTSE = self::INTERIM_KEY_TTL;
- }
- // Assume a key is hot if requested soon after invalidation
- $isHot = ( $curTTL !== null && $curTTL <= 0 && abs( $curTTL ) <= $lockTSE );
- // Use the mutex if there is no value and a busy fallback is given
- $checkBusy = ( $busyValue !== null && $value === false );
- // Decide whether a single thread should handle regenerations.
- // This avoids stampedes when $checkKeys are bumped and when preemptive
- // renegerations take too long. It also reduces regenerations while $key
- // is tombstoned. This balances cache freshness with avoiding DB load.
- $useMutex = ( $isHot || ( $isTombstone && $lockTSE > 0 ) || $checkBusy );
+ // Only a tombstoned key yields no value yet has a (negative) "current time left"
+ $isKeyTombstoned = ( $curTTL !== null && $value === false );
+ // Decide if only one thread should handle regeneration at a time
+ $useMutex =
+ // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
+ // deduce the key hotness because $curTTL will always keep increasing until the
+ // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
+ // is not set, constant regeneration of a key for the tombstone lifetime might be
+ // very expensive. Assume tombstoned keys are possibly hot in order to reduce
+ // the risk of high regeneration load after the delete() method is called.
+ $isKeyTombstoned ||
+ // Assume a key is hot if requested soon ($lockTSE seconds) after invalidation.
+ // This avoids stampedes when timestamps from $checkKeys/$touchedCallback bump.
+ ( $curTTL !== null && $curTTL <= 0 && abs( $curTTL ) <= $lockTSE ) ||
+ // Assume a key is hot if there is no value and a busy fallback is given.
+ // This avoids stampedes on eviction or preemptive regeneration taking too long.
+ ( $busyValue !== null && $value === false );
$lockAcquired = false;
if ( $useMutex ) {
// Acquire a datacenter-local non-blocking lock
if ( $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL ) ) {
- // Lock acquired; this thread should update the key
+ // Lock acquired; this thread will recompute the value and update cache
$lockAcquired = true;
- } elseif ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+ } elseif ( $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+ // Lock not acquired and a stale value exists; use the stale value
$this->stats->increment( "wanobjectcache.$kClass.hit.stale" );
- // If it cannot be acquired; then the stale value can be used
+
return $value;
} else {
- // Use the INTERIM value for tombstoned keys to reduce regeneration load.
- // For hot keys, either another thread has the lock or the lock failed;
- // use the INTERIM value from the last thread that regenerated it.
- $value = $this->getInterimValue( $key, $versioned, $minTime, $asOf );
- if ( $value !== false ) {
- $this->stats->increment( "wanobjectcache.$kClass.hit.volatile" );
-
- return $value;
+ // Lock not acquired and no stale value exists
+ if ( $isKeyTombstoned ) {
+ // Use the INTERIM value from the last thread that regenerated it if possible
+ $value = $this->getInterimValue( $key, $versioned, $minTime, $asOf );
+ if ( $value !== false ) {
+ $this->stats->increment( "wanobjectcache.$kClass.hit.volatile" );
+
+ return $value;
+ }
}
- // Use the busy fallback value if nothing else
+
if ( $busyValue !== null ) {
+ // Use the busy fallback value if nothing else
$miss = is_infinite( $minTime ) ? 'renew' : 'miss';
$this->stats->increment( "wanobjectcache.$kClass.$miss.busy" );
}
$valueIsCacheable = ( $value !== false && $ttl >= 0 );
- // When delete() is called, writes are write-holed by the tombstone,
- // so use a special INTERIM key to pass the new value around threads.
- if ( ( $isTombstone && $lockTSE > 0 ) && $valueIsCacheable ) {
- $tempTTL = max( 1, (int)$lockTSE ); // set() expects seconds
- $newAsOf = $this->getCurrentTime();
- $wrapped = $this->wrap( $value, $tempTTL, $newAsOf );
- // Avoid using set() to avoid pointless mcrouter broadcasting
- $this->setInterimValue( $key, $wrapped, $tempTTL );
- }
-
if ( $valueIsCacheable ) {
- $setOpts['lockTSE'] = $lockTSE;
- $setOpts['staleTTL'] = $staleTTL;
- // Use best known "since" timestamp if not provided
- $setOpts += [ 'since' => $preCallbackTime ];
- // Update the cache; this will fail if the key is tombstoned
- $this->set( $key, $value, $ttl, $setOpts );
+ if ( $isKeyTombstoned ) {
+ // When delete() is called, writes are write-holed by the tombstone,
+ // so use a special INTERIM key to pass the new value among threads.
+ $tempTTL = max( self::INTERIM_KEY_TTL, (int)$lockTSE ); // set() expects seconds
+ $newAsOf = $this->getCurrentTime();
+ $wrapped = $this->wrap( $value, $tempTTL, $newAsOf );
+ // Avoid using set() to avoid pointless mcrouter broadcasting
+ $this->setInterimValue( $key, $wrapped, $tempTTL );
+ } elseif ( !$useMutex || $lockAcquired ) {
+ // Save the value unless a lock-winning thread is already expected to do that
+ $setOpts['lockTSE'] = $lockTSE;
+ $setOpts['staleTTL'] = $staleTTL;
+ // Use best known "since" timestamp if not provided
+ $setOpts += [ 'since' => $preCallbackTime ];
+ // Update the cache; this will fail if the key is tombstoned
+ $this->set( $key, $value, $ttl, $setOpts );
+ }
}
if ( $lockAcquired ) {
return $value;
}
+ /**
+ * @param mixed $value
+ * @param float $asOf
+ * @param float $curTTL
+ * @param callable|null $callback
+ * @return float
+ */
+ protected function applyTouchedCallback( $value, $asOf, $curTTL, $callback ) {
+ if ( $callback === null ) {
+ return $curTTL;
+ }
+
+ if ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( "Invalid expiration callback provided." );
+ }
+
+ if ( $value !== false ) {
+ $touched = $callback( $value );
+ if ( $touched !== null && $touched >= $asOf ) {
+ $curTTL = min( $curTTL, self::TINY_NEGATIVE, $asOf - $touched );
+ }
+ }
+
+ return $curTTL;
+ }
+
/**
* @param string $key
* @param bool $versioned
$wrapped = $this->cache->get( self::INTERIM_KEY_PREFIX . $key );
list( $value ) = $this->unwrap( $wrapped, $this->getCurrentTime() );
- if ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+ if ( $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
$asOf = $wrapped[self::FLD_TIME];
return $value;
}
/**
- * Check whether $value is appropriately versioned and not older than $minTime (if set)
+ * Check if $value is not false, versioned (if needed), and not older than $minTime (if set)
*
- * @param array $value
+ * @param array|bool $value
* @param bool $versioned
* @param float $asOf The time $value was generated
* @param float $minTime The last time the main value was generated (0.0 if unknown)
* @return bool
*/
protected function isValid( $value, $versioned, $asOf, $minTime ) {
- if ( $versioned && !isset( $value[self::VFLD_VERSION] ) ) {
+ if ( $value === false ) {
+ return false;
+ } elseif ( $versioned && !isset( $value[self::VFLD_VERSION] ) ) {
return false;
} elseif ( $minTime > 0 && $asOf < $minTime ) {
return false;