File: /home/xedaptot/be.naniguide.com/app/Library/RollingRateTracker.php
<?php
namespace App\Library;
use App\Library\Exception\RateLimitExceeded;
use Carbon\Carbon;
use Exception;
class RollingRateTracker implements RateTrackerInterface
{
protected const STATE_VERSION = 1;
protected const SUPPORTED_UNITS = ['minute', 'hour', 'day'];
protected $filepath;
protected $limits;
protected $options;
protected $retentionPeriod;
protected $separator = ':';
protected $blockFormat = 'YmdHi';
public function __construct(string $filepath, $limits = [], array $options = [])
{
$this->filepath = $filepath;
$this->limits = $limits;
$this->options = $options;
$this->retentionPeriod = $options['retention_period'] ?? null;
$this->validateLimits();
$this->validateRetentionPeriod();
$this->createStorageFile();
}
public function test(Carbon $now = null, $failedCallback = null)
{
$lock = new Lockable($this->filepath);
$lock->getSharedLock(function ($fopen) use ($now, $failedCallback) {
$now = $now ?: Carbon::now();
$state = $this->readState($fopen);
$this->doTest($now, $state, $failedCallback);
}, $timeout = 15);
}
public function count(Carbon $now = null, $failedCallback = null)
{
$lock = new Lockable($this->filepath);
$lock->getExclusiveLock(function ($fopen) use ($now, $failedCallback) {
$now = $now ?: Carbon::now();
$state = $this->readState($fopen);
$this->pruneStateByRetention($state, $now);
$this->doTest($now, $state, $failedCallback);
$block = $this->makeBlock($now);
$state['minutes'][$block] = ($state['minutes'][$block] ?? 0) + 1;
$this->writeState($state, $fopen);
}, $timeout = 15);
}
public function rollback()
{
$lock = new Lockable($this->filepath);
$lock->getExclusiveLock(function ($fopen) {
$state = $this->readState($fopen);
if (empty($state['minutes'])) {
throw new Exception('Cannot rollback! There is no previous count!');
}
ksort($state['minutes']);
$lastBlock = array_key_last($state['minutes']);
$count = $state['minutes'][$lastBlock];
if ($count <= 1) {
unset($state['minutes'][$lastBlock]);
} else {
$state['minutes'][$lastBlock] = $count - 1;
}
$this->writeState($state, $fopen);
}, $timeout = 15);
}
public function getRecords(Carbon $fromDatetime = null, Carbon $toDatetime = null)
{
$records = [];
$fromDatetime = $fromDatetime ?: Carbon::createFromTimestamp(0);
$toDatetime = $toDatetime ?: Carbon::now();
$fromBlock = $this->makeBlock($fromDatetime);
$toBlock = $this->makeBlock($toDatetime);
$lock = new Lockable($this->filepath);
$lock->getSharedLock(function ($fopen) use (&$records, $fromBlock, $toBlock) {
$state = $this->readState($fopen);
$this->assertRangeWithinRetention($state, $fromBlock);
foreach ($state['minutes'] as $block => $count) {
$block = (string) $block;
if ($block >= $fromBlock && $block <= $toBlock) {
$records[] = [$block, $count];
}
}
}, $timeout = 15);
usort($records, function ($left, $right) {
return strcmp($left[0], $right[0]);
});
return $records;
}
public function getCreditsUsed(Carbon $fromDatetime = null, Carbon $toDatetime = null)
{
$records = $this->getRecords($fromDatetime, $toDatetime);
$counts = array_map(function ($record) {
return $record[1];
}, $records);
return array_sum($counts);
}
public function getLockFilePath()
{
return $this->filepath;
}
public function getRateLimits()
{
return $this->limits;
}
public function cleanup(string $period = null)
{
$lock = new Lockable($this->filepath);
$lock->getExclusiveLock(function ($fopen) use ($period) {
if (is_null($period)) {
$this->writeState($this->defaultState(), $fopen);
return;
}
$state = $this->readState($fopen);
$fromBlock = $this->makeBlock(now()->subtract($period));
$state['minutes'] = array_filter(
$state['minutes'],
function ($count, $block) use ($fromBlock) {
return $block >= $fromBlock;
},
ARRAY_FILTER_USE_BOTH
);
$this->writeState($state, $fopen);
}, $timeout = 15);
}
public function getLimitsDescription()
{
$descriptions = [];
foreach ($this->getRateLimits() as $limit) {
$descriptions[] = $limit->getDescription();
}
return implode(', ', $descriptions);
}
public function buildRecord($block, $count)
{
return "{$block}{$this->separator}{$count}";
}
public function makeBlock($now)
{
$now = $now ?: Carbon::now();
return $now->format($this->blockFormat);
}
private function createStorageFile()
{
if (!file_exists($this->filepath)) {
touch($this->filepath);
}
}
private function validateLimits()
{
foreach ($this->limits as $limit) {
if (!in_array($limit->getPeriodUnit(), self::SUPPORTED_UNITS, true)) {
throw new Exception('RollingRateTracker only supports minute/hour/day rate limits');
}
}
}
private function validateRetentionPeriod()
{
if (is_null($this->retentionPeriod)) {
return;
}
$retentionSeconds = $this->periodToSeconds($this->retentionPeriod);
foreach ($this->limits as $limit) {
$limitPeriod = sprintf('%s %s', $limit->getPeriodValue(), $limit->getPeriodUnit());
if ($retentionSeconds < $this->periodToSeconds($limitPeriod)) {
throw new Exception(sprintf(
'RollingRateTracker retention_period "%s" is shorter than the configured rate limit window "%s". retention_period must be at least as long as the longest rate limit window.',
$this->retentionPeriod,
$limitPeriod
));
}
}
}
private function doTest(Carbon $now, array $state, $failedCallback)
{
foreach ($this->limits as $limit) {
$period = sprintf('%s %s', $limit->getPeriodValue(), $limit->getPeriodUnit());
$fromDatetime = $now->copy()->subtract($period);
$creditsUsed = $this->getCreditsUsedFromState($state, $fromDatetime, $now);
if ($creditsUsed >= $limit->getAmount()) {
$msg = sprintf("%s exceeded! %s/%s used", $limit->getDescription(), $creditsUsed, $limit->getAmount());
if ($failedCallback) {
$failedCallback($msg);
}
throw new RateLimitExceeded($msg);
}
}
}
private function getCreditsUsedFromState(array $state, Carbon $fromDatetime, Carbon $toDatetime)
{
$fromBlock = $this->makeBlock($fromDatetime);
$toBlock = $this->makeBlock($toDatetime);
$total = 0;
foreach ($state['minutes'] as $block => $count) {
$block = (string) $block;
if ($block >= $fromBlock && $block <= $toBlock) {
$total += $count;
}
}
return $total;
}
private function readState($fopen)
{
rewind($fopen);
$contents = stream_get_contents($fopen);
if ($contents === false || trim($contents) === '') {
return $this->defaultState();
}
$state = json_decode($contents, true);
if (!is_array($state) || !isset($state['minutes']) || !is_array($state['minutes'])) {
throw new Exception("Invalid rolling rate tracker state at {$this->filepath}");
}
ksort($state['minutes']);
return $state;
}
private function pruneStateByRetention(array &$state, Carbon $now)
{
if (is_null($this->retentionPeriod)) {
return;
}
$fromBlock = $this->makeBlock($now->copy()->subtract($this->retentionPeriod));
$state['minutes'] = array_filter(
$state['minutes'],
function ($count, $block) use ($fromBlock) {
$block = (string) $block;
return $block >= $fromBlock;
},
ARRAY_FILTER_USE_BOTH
);
}
private function assertRangeWithinRetention(array $state, string $fromBlock)
{
if (is_null($this->retentionPeriod) || empty($state['minutes'])) {
return;
}
ksort($state['minutes']);
$latestBlock = (string) array_key_last($state['minutes']);
$earliestRetainedBlock = $this->makeBlock(
$this->parseBlockToCarbon($latestBlock)->sub($this->retentionPeriod)
);
if ($fromBlock < $earliestRetainedBlock) {
throw new Exception(sprintf(
'RollingRateTracker query start "%s" is older than the retained data window. With retention_period "%s" and latest retained block "%s", the earliest reliable block is "%s".',
$fromBlock,
$this->retentionPeriod,
$latestBlock,
$earliestRetainedBlock
));
}
}
private function writeState(array $state, $fopen)
{
ksort($state['minutes']);
$payload = json_encode($state);
if ($payload === false) {
throw new Exception("Cannot encode rolling rate tracker state at {$this->filepath}");
}
ftruncate($fopen, 0);
rewind($fopen);
fwrite($fopen, $payload);
}
private function defaultState()
{
return [
'version' => self::STATE_VERSION,
'minutes' => [],
];
}
private function periodToSeconds(string $period)
{
if (!preg_match('/^\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*$/', $period, $matches)) {
throw new Exception('RollingRateTracker period must use minute/hour/day units, for example "90 minute" or "2 day"');
}
$value = (int) $matches[1];
$unit = rtrim($matches[2], 's');
return match ($unit) {
'minute' => $value * 60,
'hour' => $value * 3600,
'day' => $value * 86400,
default => throw new Exception('RollingRateTracker period must use minute/hour/day units'),
};
}
private function parseBlockToCarbon(string $block)
{
$datetime = Carbon::createFromFormat($this->blockFormat, $block);
if ($datetime === false) {
throw new Exception("Invalid rolling rate tracker block \"{$block}\"");
}
return $datetime;
}
}