getQueryPartExpiryTime()); $pattern = str_replace('*', '%', $pattern); $keys = Db::fetchAll($sql, array($pattern)); $raw = array_column($keys, 'key'); return $raw; } public function setIfNotExists($key, $value, $ttlInSeconds) { if (empty($ttlInSeconds)) { $ttlInSeconds = 999999999; } // FYI: We used to have an INSERT INTO ... ON DUPLICATE UPDATE ... However, this can be problematic in concurrency issues // because the ON DUPLICATE UPDATE may work successfully for 2 jobs at the same time but only one of them got the lock then. // This would be perfectly fine if we did something like `return $this->get($key) === $value` to 100% detect which process // got the lock as we do now. However, maybe the expireTime gets overwritten with a wrong value or so. That's why we // rather try to get the lock with the insert only because only one job can succeed with this. If below flow with the // delete becomes to slow, we may be able to use the INSERT INTO ... ON DUPLICATE UPDATE again. if ($this->get($key)) { return false; // a value is set, won't be possible to insert } $tablePrefixed = self::getTableName(); // remove any existing but expired lock // todo: we could combine get() and keyExists() in one query! if ($this->keyExists($key)) { // most of the time an expired key should not exist... we don't want to lock the row unnecessarily therefore we check first // if value exists... $sql = sprintf('DELETE FROM %s WHERE `key` = ? and not (%s)', $tablePrefixed, $this->getQueryPartExpiryTime()); Db::query($sql, array($key)); } $query = sprintf('INSERT INTO %s (`key`, `value`, `expiry_time`) VALUES (?,?,(UNIX_TIMESTAMP() + ?))', $tablePrefixed); // we make sure to update the row if the key is expired and consider it as "deleted" try { Db::query($query, array($key, $value, (int) $ttlInSeconds)); } catch (\Exception $e) { if ($e->getCode() == 23000 || strpos($e->getMessage(), 'Duplicate entry') !== false || strpos($e->getMessage(), ' 1062 ') !== false) { return false; } throw $e; } // we make sure we got the lock return $this->get($key) === $value; } public function get($key) { $sql = sprintf('SELECT SQL_NO_CACHE `value` FROM %s WHERE `key` = ? AND %s LIMIT 1', self::getTableName(), $this->getQueryPartExpiryTime()); return Db::fetchOne($sql, array($key)); } public function deleteIfKeyHasValue($key, $value) { if (empty($value)) { return false; } $sql = sprintf('DELETE FROM %s WHERE `key` = ? and `value` = ?', self::getTableName()); return $this->queryDidMakeChange($sql, array($key, $value)); } public function expireIfKeyHasValue($key, $value, $ttlInSeconds) { if (empty($value)) { return false; } // we need to use unix_timestamp in mysql and not time() in php since the local time might be different on each server // better to rely on one central DB server time only $sql = sprintf('UPDATE %s SET expiry_time = (UNIX_TIMESTAMP() + ?) WHERE `key` = ? and `value` = ?', self::getTableName()); $success = $this->queryDidMakeChange($sql, array((int) $ttlInSeconds, $key, $value)); if (!$success) { // the above update did not work because the same time was already set and we just tried to set the same ttl // again too fast within one second return $value === $this->get($key); } return true; } public function keyExists($key) { $sql = sprintf('SELECT SQL_NO_CACHE 1 FROM %s WHERE `key` = ? LIMIT 1', self::getTableName()); $value = Db::fetchOne($sql, array($key)); return !empty($value); } private function queryDidMakeChange($sql, $bind = array()) { $query = Db::query($sql, $bind); if (is_object($query) && method_exists($query, 'rowCount')) { // anything else but mysqli in tracker mode return (bool) $query->rowCount(); } else { // mysqli in tracker mode return (bool) Db::get()->rowCount($query); } } private static function getTableName() { return Common::prefixTable(self::TABLE_NAME); } private function getQueryPartExpiryTime() { return 'UNIX_TIMESTAMP() <= expiry_time'; } }