HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Library/DynamicRateTracker.php
<?php

namespace App\Library;

use Illuminate\Support\Carbon;
use Exception;
use App\Library\Exception\RateLimitExceeded;
use App\Library\Exception\RateLimitReservedByAnotherFileSystem;
use Closure;
use Illuminate\Support\Facades\Cache;
use App\Library\RateTrackerInterface;

/*
 * Limitation of this algorithm
 *
 *   + What if a process reserves all the credits but did not use it all?
 *   + It is even worse if the credits reservation is too long (no other process of another server can use it)
 *       - even if we use only 01 dedicated worker per campaign,
 *         we still have to deal with resource sharing for global sending servers (used by many customers and campaigns)
 *
 *   + Doubled credits used at window point
 *
 */

class DynamicRateTracker implements RateTrackerInterface
{
    protected $resourceKey;
    protected $reservedPath;
    protected $remoteLock;

    protected $limits;

    protected $seperator = ':';
    protected $hostname;

    public function __construct(string $resourceKey, $limits = []) // RateLimit class
    {
        $this->resourceKey = $resourceKey;

        // IMPORTANT: this path is shared among processes of the same server (same filesystem)
        $this->reservedPath = "/tmp/dynamic-rate-tracker-pid-".md5($this->resourceKey);

        // Store the datetime until remote reservation is expired
        $this->remoteLock = "/tmp/dynamic-rate-tracker-pid-".md5($this->resourceKey)."-remote-lock";

        $this->limits = $limits;
        $this->hostname = gethostname() ?: 'Unknown';

        if (!file_exists($this->reservedPath)) {
            touch($this->reservedPath);
        }
    }

    public function getReservedPath()
    {
        return $this->reservedPath;
    }

    // Reverse of count()
    // @deprecated: rollback is not needed as even a failed operation is also counted in rate limits
    public function rollback()
    {
        // This is important, in case a tracker is counted (successfully, returning ok) in the execute_with_limits() function
        // and is later on rolled back
        if (empty($this->limits)) {
            return;
        }

        $lock = new Lockable($this->reservedPath);
        $lock->getExclusiveLock(function ($fopen) {
            list($until, $credits) = $this->parseReservedCredits($fopen);

            if ($until) {
                $this->updateReservedCredits($until, $credits + 1);
            } else {
                // do nothing as there is no reserved credits avaialable
            }

        });
    }

    public function getRateLimits()
    {
        return $this->limits;
    }

    // Example of $period: "24 hours", "1 week"
    // i.e. Clean up credit tracking logs that are older than "24 hours", "1 week"
    public function cleanup(string $period = null)
    {
        // no cleanup needed
    }

    private function updateReservedCredits(Carbon $until, $credits)
    {
        file_put_contents($this->reservedPath, "{$until->timestamp}{$this->seperator}{$credits}");
    }

    private function cacheRemoteLock($until)
    {
        file_put_contents($this->remoteLock, "{$until->timestamp}");
    }

    private function isRemoteLockCached()
    {
        if (!file_exists($this->remoteLock)) {
            return;
        }

        $until = file_get_contents($this->remoteLock);

        if (empty($until)) {
            return;
        }

        $untilObject = new Carbon((int)$until);

        if ($untilObject->gt(now())) {
            return $untilObject;
        } else {
            return;
        }
    }

    private function countReservedCredits(Carbon $now, $fopen, $failedCallback)
    {
        list($until, $credits) = $this->parseReservedCredits($fopen);

        if ($until && $until->gte($now)) {
            // locally reserved

            if ($credits > RateLimit::ZERO) {
                // if local credits available
                $this->updateReservedCredits($until, $credits - 1);

                return true;
            } else {
                $msg = "Calculated rate limit exceeded. Currently reserved by this host {$this->hostname}, but no credits left: {$this->getMinCredits()}/{$this->getShortestLimitPeriod()} ({$this->getLimitsDescription()}). Reserved until {$until->format('H:i T')}, {$until->diffForHumans()}";

                if ($failedCallback) {
                    $failedCallback($msg);
                }
                // if locally reserved but no credits left
                throw new RateLimitExceeded($msg);

            }
        } else {
            // Not locally reserved
            return false;
        }
    }

    // Test and throw exception, do not count()
    // Used by DelayedJob
    public function test(Carbon $now = null, $failedCallback = null)
    {
        $this->doCount($now, $count = false, $failedCallback);
    }

    public function count(Carbon $now = null, $failedCallback = null)
    {
        $this->doCount($now, $count = true, $failedCallback);
    }

    private function doCount(Carbon $now = null, $count = true, $failedCallback = null)
    {
        if (empty($this->limits)) {
            return;
        }

        $lock = new Lockable($this->reservedPath);
        $lock->getExclusiveLock(function ($fopen) use ($now, $count, $failedCallback) {
            $now = $now ?: Carbon::now();

            // If there is a valid local reservation, 3 possible outcome
            // - true: OK
            // - false: no local reservation, check remote....
            // - Exception: reserved by this host but no credits left
            if ($this->countReservedCredits($now, $fopen, $failedCallback)) {
                return;
            }

            // Else, no local reservation, checking remote

            // A valid remote reservation which was previously cached (local check)
            if ($until = $this->isRemoteLockCached()) {
                $msg = "Currently reserved by another process/filesystem (current host: {$this->hostname}): {$this->getMinCredits()}/{$this->getShortestLimitPeriod()} ({$this->getLimitsDescription()}). Will be available for booking in {$until->diffForHumans()}, at {$until->format('H:i T')}";

                if ($failedCallback) {
                    $failedCallback($msg);
                }

                throw new RateLimitReservedByAnotherFileSystem(
                    $msg,
                    $code = 0,
                    $previous = null,
                    $until // pass this additional parameter to Exception
                );
            }


            // In the worst case, connect to remote....
            with_cache_lock($this->resourceKey, function () use ($now, $count, $failedCallback) {
                // returning null or an active reservation Carbon object
                if ($until = $this->isReservedByOthers($now)) {
                    $msg = "***Currently reserved by another process/filesystem (current host: {$this->hostname}): {$this->getMinCredits()}/{$this->getShortestLimitPeriod()} ({$this->getLimitsDescription()}). Will be available for booking in {$until->diffForHumans()}, at {$until->format('H:i T')}";

                    if ($failedCallback) {
                        $failedCallback($msg);
                    }

                    // Cache here, so other process does not have to come all the way here
                    // They will be able to stop earlier at isRemoteLockCache() check above
                    // A remote request is eliminated
                    $this->cacheRemoteLock($until);

                    throw new RateLimitReservedByAnotherFileSystem(
                        $msg,
                        $code = 0,
                        $previous = null,
                        $until // pass this additional parameter to Exception
                    );
                }

                if (!$count) {
                    // this is for Delay job only
                    // test if it shoudl resume a campaign, do not count ONE use credits
                    return;
                }

                // Okie, so ready
                $until = $now->add($this->getShortestLimitPeriod());
                $credits = $this->getMinCredits();

                // reserve credits for local & remote
                // also count 1 credit used
                $this->reserve($until, $credits - 1);

            }, $timeout = 15);
        });
    }

    // Return a Carbon object if reserved by remote peer
    // Return null if no reservation or reservation is already expired
    public function isReservedByOthers(Carbon $now)
    {
        $reservedUntil = Cache::get($this->resourceKey);

        if (!is_null($reservedUntil)) {
            $untilObj = new Carbon((int)$reservedUntil);
            if ($untilObj->gte($now)) {
                return $untilObj;
            }
        }

        return null;
    }

    private function reserve(Carbon $until, $credits)
    {
        // Set local
        $this->updateReservedCredits($until, $credits);

        // Set remote reserved flag
        Cache::put($this->resourceKey, $until->timestamp);
    }

    public function parseReservedCredits($fopen = null)
    {
        if (is_null($fopen)) {
            $fopen = fopen($this->getReservedPath(), 'r');
        }

        rewind($fopen);
        $contents = fgets($fopen);
        if (empty($contents)) {
            return [null, null];
        }

        list($until, $credits) = explode($this->seperator, $contents);

        return [new Carbon((int)$until), (int)$credits];
    }

    public function getShortestLimitPeriod()
    {
        $shortest = null;
        $now = now();
        foreach ($this->limits as $limit) {
            $d = $now->copy()->add($limit->getPeriod());
            if (is_null($shortest) || $now->copy()->add($shortest->getPeriod())->gte($d)) {
                $shortest = $limit;
            }
        }

        return $shortest->getPeriod();
    }

    public function getMinCredits()
    {
        $min = null;
        foreach ($this->limits as $limit) {
            if (is_null($min) || $limit->getAmount() < $min) {
                $min = $limit->getAmount();
            }
        }

        return $min;
    }

    public function clearReservation()
    {
        file_put_contents($this->getReservedPath(), '');
        Cache::forget($this->resourceKey);
    }

    public function clearLocalReservation()
    {
        file_put_contents($this->getReservedPath(), '');
    }

    public function getLimitsDescription()
    {
        $str = [];
        foreach ($this->getRateLimits() as $limit) {
            $str[] = $limit->getDescription();
        }

        return implode(', ', $str);
    }
}