<?php namespace App\Library; use Carbon\Carbon; use DateInterval; use Exception; class QuotaTrackerFile { protected $file; protected $hasLock = false; protected $limits; protected $quota; protected $series; protected $archived; const TIMEOUT = 60; const UNLIMITED = -1; /** * Constructor, modeling data from a JSON array. * * @param string $file * @param null $quota , for example $quota = ['start' => 'Jan 1, 2016', 'interval' => '1 month', 'max' => ] * @param array $limits * * @throws Exception */ public function __construct(string $file, $quota = null, array $limits = []) { $this->file = $file; $this->quota = $quota; $this->limits = $limits; $this->createLogFile(); } /** * Create the lock file itself if not exist. * * @throws Exception */ private function createLogFile(): bool { if (file_exists($this->file)) { return true; } // lock at application level $fp = fopen(storage_path('lock'), 'r+'); $start = time(); while (true) { $this->timeout($start); if (flock($fp, LOCK_EX | LOCK_NB)) { $old = umask(0); // create the base directories for log files if ( ! file_exists(dirname($this->file))) { mkdir(dirname($this->file), 0777, true); } if ( ! file_exists($this->file)) { // init an empty file if not exist touch($this->file); // change file permission chmod($this->file, 0777); } // change umask back to its original value umask($old); // unlock flock($fp, LOCK_UN); break; } } fclose($fp); return false; } /** * Check if over quota, add a time point. * * @param Carbon|null $timePoint * @param int $unit_value * * @return bool * @throws Exception */ public function add(Carbon $timePoint = null, int $unit_value = 0): bool { if ( ! isset($timePoint)) { $timePoint = Carbon::now(); } $mode = 'a'; $this->getExclusiveLock($mode, function ($writer) use ($timePoint, $unit_value) { if ($unit_value == 0) { fwrite($writer, ','.$timePoint->timestamp); } else { for ($i = 0; $i < $unit_value; $i++) { fwrite($writer, ','.$timePoint->timestamp); } } fflush($writer); }); return true; } /** * Check if over quota. * * @param Carbon|null $timePoint * * @return mixed * @throws Exception */ public function check(Carbon $timePoint = null) { if ( ! isset($timePoint)) { $timePoint = Carbon::now(); } $this->getSharedLock(function () use (&$result, $timePoint) { $result = true; # check quota # the trick is that 2nd parameter is null, indicating a quota check if ( ! is_null($this->quota)) { if (self::UNLIMITED != $this->quota['max'] && $this->getUsage($timePoint) >= $this->quota['max']) { $result = false; return; } } foreach ($this->limits as $interval => $max) { if (self::UNLIMITED == $max) { continue; } # check limit # there are 2 types of usage # 1. usage within the last n hour/day/month # 2. usage since the beginning (of the quota period) if ($this->getUsage($timePoint, $interval) >= $max) { $result = false; break; } } }); return $result; } /** * Shift the time series until its range fits the new time point. * * @param Carbon $timePoint * @param $interval * * @return array */ private function shiftBy(Carbon $timePoint, $interval): array { // @interval MUST be less than 'history_max_length' if (empty($this->series)) { return []; } # Conventions: # - $timePoint is not null # - $interval empty means get the series from the quota start date # - $interval not empty means get the series from the interval # - If both $interval and $quota empty --> for backward compatibility --> get quota from the first interval of $this->limit if (is_null($interval)) { if (is_null($this->quota)) { $cutOff = $timePoint->copy()->sub(DateInterval::createFromDateString(array_keys($this->limits)[0]))->timestamp; } else { $cutOff = $this->quota['start']; } } else { $cutOff = $timePoint->copy()->sub(DateInterval::createFromDateString($interval))->timestamp; } // cut off the series from the point $offset = null; foreach ($this->series as $i => $value) { if ($value >= $cutOff) { $offset = $i; break; } } if (is_null($offset)) { return []; } else { return array_slice($this->series, $offset); // including offset } } /** * Renew the quota data for the tracker. * * @note this method MUST be wrapped in a transaction * * @throws Exception */ public function reset() { $this->getExclusiveLock('r+', function ($writer) { ftruncate($writer, 0); }); } /** * Get the first data point of the time series. * * @return mixed data point */ private function first() { return (empty($this->series)) ? null : $this->series[0]; } /** * Get the last data point of the time series. * * @return mixed data point */ private function last() { return (empty($this->series)) ? null : $this->series[sizeof($this->series) - 1]; } /** * Count the time series length. * * @transaction-safe * * @param null $timePoint * @param null $interval * * @return int count * @throws Exception */ public function getUsage($timePoint = null, $interval = null): int { $this->getSharedLock(function () use (&$result, $timePoint, $interval) { if (is_null($interval)) { [$start, $value] = $this->archived; $result = $value + sizeof($this->getSeries($timePoint, $interval)); } else { $result = sizeof($this->getSeries($timePoint, $interval)); } }); return $result; } /** * Get usage percentage. * * @param null $timePoint * @param null $interval * * @return float * @throws Exception */ public function getUsagePercentage($timePoint = null, $interval = null) { if (is_null($this->quota)) { return $this->getUsage($timePoint, $interval) / array_values($this->limits)[0]; } return (float) $this->getUsage($timePoint, $interval) / $this->quota['max']; } /** * Get the data series. * * @transaction-safe * * @param Carbon|null $timePoint * @param null $interval * * @return array data series * @throws Exception */ public function getSeries(Carbon $timePoint = null, $interval = null): array { if (is_null($timePoint)) { $timePoint = Carbon::now(); } $this->getSharedLock(function () use (&$series, $timePoint, $interval) { $series = $this->shiftBy($timePoint, $interval); }); return $series; } /** * Clean up the series data that are older than quota['max']. * * @transaction-safe * * @param null $timePoint * @param string $interval * * @throws Exception */ public function cleanupSeries($timePoint = null, $interval = '1 month') { if (is_null($timePoint)) { $timePoint = Carbon::now(); } $mode = 'r+'; $this->getExclusiveLock($mode, function ($writer) use ($timePoint, $interval) { $this->load(); // rebasing the start [$currentStart, $currentValue] = $this->archived; if (is_null($currentStart) || $currentStart < $this->quota['start']) { $this->archived = [$this->quota['start'], 0]; } elseif ($currentStart > $this->quota['start']) { $this->archived = [$this->quota['start'], $currentValue]; } // strip series coming before the start date $offset = null; foreach ($this->series as $i => $value) { if ($value >= $this->quota['start']) { $offset = $i; break; } } $this->series = is_null($offset) ? [] : array_slice($this->series, $offset); // compute the archive as well as reset the series $cutOff = $timePoint->copy()->sub(DateInterval::createFromDateString($interval))->timestamp; $offset = null; foreach ($this->series as $i => $value) { if ($value >= $cutOff) { $offset = $i; break; } } if (is_null($offset)) { $archived = $this->series; $newSeries = []; } else { $archived = array_slice($this->series, 0, $offset); // offset acts as limit $newSeries = array_slice($this->series, $offset); } // retrieve the latest archive data [$start, $value] = $this->archived; $value += count($archived); $archivedStr = sprintf('%s:%s|', $start, $value); // write to file ftruncate($writer, 0); fwrite($writer, $archivedStr.implode(',', $newSeries)); fflush($writer); }); } /** * Get exclusive lock, preparing to read/write quota data. * * @param $mode * @param $callback * * @throws Exception * @deprecated due to performance issue (write the entire series to the file, mod "w") */ public function getExclusiveLock($mode, $callback) { $reader = fopen($this->file, $mode); $start = time(); while (true) { // if timed out $this->timeout($start); if (flock($reader, LOCK_EX | LOCK_NB)) { // acquire an exclusive lock $this->hasLock = true; // execute the callback $callback($reader); fflush($reader); $this->hasLock = false; flock($reader, LOCK_UN); // release the lock fclose($reader); break; } // Otherwise, loop and wait to for the lock } } /** * Get shared lock, preparing to read quota data. * * @param $callback * * @throws Exception */ public function getSharedLock($callback) { if ($this->hasLock) { // execute the callback $callback($this); return; } $this->createLogFile(); $reader = fopen($this->file, 'r'); $start = time(); while (true) { // if timed out $this->timeout($start); if (flock($reader, LOCK_SH | LOCK_NB)) { // acquire an exclusive lock $this->hasLock = true; // retrieve the series data $this->load(); // execute the callback $callback($this); $this->hasLock = false; flock($reader, LOCK_UN); // release the lock fclose($reader); break; } // Otherwise, loop and wait to for the lock } } /** * Load time series from file. * * @return array time series */ private function load(): array { $series = file_get_contents($this->file); preg_match('/(?<archived>^[^\|]{1,24})\|/', $series, $matched); if (array_key_exists('archived', $matched)) { $this->archived = array_map('intval', explode(':', $matched['archived'])); // [ $start, $value] } else { $this->archived = [null, 0]; } // @note the method below is deprecated as it is quite slow // return array_map('intval', array_values(array_filter(explode(',', file_get_contents($this->file))))); // a faster way is to use json_decode // REXP to replace anything before the pipe or leading/trailing comma $this->series = json_decode('['.preg_replace('/(^(.*\|,*|,+)|,+$)/', '', $series).']'); return $this->series; } /** * Time out a lock request when it exceeds the QUOTA setting. * * @param $start * * @throws Exception */ private function timeout($start) { if (time() - $start > self::TIMEOUT) { throw new Exception('Timeout getting lock'); } } /** * Get the raw series data (for testing only). * * @return array series */ public function getRawSeries(): array { return $this->load(); } /** * Get the raw archived info (for testing only). * * @return array series */ public function getRawArchived(): array { return $this->archived; } /** * Dump the quota track configuration settings. * * @return array settings * @throws Exception */ public function dump(): array { return [ 'file' => $this->file, 'quota' => $this->quota, 'limits' => $this->limits, 'usage' => $this->getUsage(), ]; } }