diff --git a/.gitattributes b/.gitattributes index fa173dd..6fc21e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,5 @@ /psalm-baseline.xml export-ignore /psalm.xml export-ignore /tests/ export-ignore +/.Dockerfile export-ignore +/.Makefile export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdf2f73..6bb085d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,60 +24,48 @@ jobs: parallel-lint: name: 'ParallelLint' runs-on: 'ubuntu-latest' - steps: - name: Checkout repository uses: actions/checkout@v3 - - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - - name: Install dependencies uses: ramsey/composer-install@v2 - - name: Run ParallelLint run: composer parallel-lint -- --no-progress --ignore-fails psalm: name: 'Psalm' runs-on: 'ubuntu-latest' - steps: - name: Checkout repository uses: actions/checkout@v3 - - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - - name: Install dependencies uses: ramsey/composer-install@v2 - - name: Run Psalm run: composer psalm -- --no-progress --no-cache --output-format=github php-cs-fixer: name: 'PHPCsFixer' runs-on: 'ubuntu-latest' - steps: - name: Checkout repository uses: actions/checkout@v3 - - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - - name: Install dependencies uses: ramsey/composer-install@v2 - - name: Run PHPCsFixer run: composer php-cs-fixer:diff -- --no-interaction --using-cache=no @@ -85,7 +73,6 @@ jobs: name: 'PHPUnit' needs: ['parallel-lint', 'psalm', 'php-cs-fixer'] runs-on: ${{ matrix.operating-system }} - strategy: fail-fast: false matrix: @@ -94,24 +81,21 @@ jobs: - 'windows-latest' php-version: - '8.1' + - '8.2' composer-dependency: - 'lowest' - 'highest' - steps: - name: Checkout repository uses: actions/checkout@v3 - - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} coverage: none - - name: Install dependencies uses: ramsey/composer-install@v2 with: dependency-versions: ${{ matrix.composer-dependency }} - - name: Run PHPUnit run: composer phpunit -- --no-interaction --do-not-cache-result diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index fb017ec..790a992 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -17,6 +17,8 @@ ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, + 'phpdoc_separation' => false, + 'phpdoc_to_comment' => false, ]) ->setFinder($finder) ; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6541a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM php:8.2-cli + +RUN \ + apt-get update ; \ + apt-get install -y unzip ; \ + pecl install pcov ; \ + docker-php-ext-enable pcov ; + +COPY --from=composer:2.4 /usr/bin/composer /usr/local/bin/composer + +WORKDIR /usr/local/packages/throttler/ diff --git a/LICENSE b/LICENSE index 880306b..cb8d327 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2022 Orangesoft +Copyright (c) 2021 Orangesoft Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..42539b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +init: + docker build -t throttler:8.2 ./ + +exec: + docker run --name throttler --rm --interactive --tty --volume ${PWD}:/usr/local/packages/throttler/ throttler:8.2 /bin/bash diff --git a/README.md b/README.md index 4344d00..87664ba 100644 --- a/README.md +++ b/README.md @@ -20,38 +20,59 @@ This package requires PHP 8.1 or later. ## Quick usage -Configure Throttler as below: +Configure `Orangesoft\Throttler\WeightedRoundRobinThrottler::class` as below and set weight for each node if you are using weighted strategy: ```php pick($collection); - + // ... } ``` -Set weight for Node as the second argument in constructor if you are using weighted-strategies. +As a result, the throttler will go through all the nodes and return the appropriate one according to the chosen strategy as shown below: + +```text ++---------+-------------+ +| request | node | ++---------+-------------+ +| 1 | 192.168.0.1 | +| 2 | 192.168.0.1 | +| 3 | 192.168.0.1 | +| 4 | 192.168.0.1 | +| 5 | 192.168.0.1 | +| 6 | 192.168.0.2 | +| 7 | 192.168.0.3 | +| n | etc. | ++---------+-------------+ +``` + +The following throttlers are available: + +- [Orangesoft\Throttler\RandomThrottler](./src/RandomThrottler.php) +- [Orangesoft\Throttler\WeightedRandomThrottler](./src/WeightedRandomThrottler.php) +- [Orangesoft\Throttler\FrequencyRandomThrottler](./src/FrequencyRandomThrottler.php) +- [Orangesoft\Throttler\RoundRobinThrottler](./src/RoundRobinThrottler.php) +- [Orangesoft\Throttler\WeightedRoundRobinThrottler](./src/WeightedRoundRobinThrottler.php) +- [Orangesoft\Throttler\SmoothWeightedRoundRobinThrottler](./src/SmoothWeightedRoundRobinThrottler.php) ## Benchmarks @@ -61,25 +82,25 @@ Run `composer phpbench` to check out benchmarks: +-------------------------------+------+-----+----------+----------+----------+---------+ | benchmark | revs | its | mean | best | worst | stdev | +-------------------------------+------+-----+----------+----------+----------+---------+ -| FrequencyRandomBench | 1000 | 5 | 6.074μs | 5.924μs | 6.242μs | 0.139μs | | RandomBench | 1000 | 5 | 4.002μs | 3.880μs | 4.097μs | 0.073μs | -| RoundRobinBench | 1000 | 5 | 4.060μs | 3.888μs | 4.363μs | 0.171μs | -| SmoothWeightedRoundRobinBench | 1000 | 5 | 6.888μs | 6.707μs | 7.102μs | 0.130μs | | WeightedRandomBench | 1000 | 5 | 11.660μs | 11.533μs | 11.797μs | 0.094μs | +| FrequencyRandomBench | 1000 | 5 | 6.074μs | 5.924μs | 6.242μs | 0.139μs | +| RoundRobinBench | 1000 | 5 | 4.060μs | 3.888μs | 4.363μs | 0.171μs | | WeightedRoundRobinBench | 1000 | 5 | 10.778μs | 10.655μs | 10.919μs | 0.115μs | +| SmoothWeightedRoundRobinBench | 1000 | 5 | 6.888μs | 6.707μs | 7.102μs | 0.130μs | +-------------------------------+------+-----+----------+----------+----------+---------+ ``` -The report is based on measuring the speed. Check `best` column to find out which strategy is the fastest. You can see that the fastest strategies are Random and RoundRobin. +The report is based on measuring the speed. Check `best` column to find out which strategy is the fastest. ## Documentation -- [Configure Throttler](docs/index.md#configure-throttler) -- [Available strategies](docs/index.md#available-strategies) -- [Sort nodes](docs/index.md#sort-nodes) -- [Keep counter](docs/index.md#keep-counter) -- [Serialize strategies](docs/index.md#serialize-strategies) -- [Dynamically change strategy](docs/index.md#dynamically-change-strategy) -- [Balance cluster](docs/index.md#balance-cluster) +- [Available strategies](./docs/index.md#available-strategies) +- [Keep states](./docs/index.md#keep-states) +- [Custom counter](./docs/index.md#custom-counter) +- [Custom strategy](./docs/index.md#custom-strategy) +- [Multiple throttler](./docs/index.md#multiple-throttler) +- [Balance cluster](./docs/index.md#balance-cluster) +- [Guzzle middleware](./docs/index.md#guzzle-middleware) -Read more about usage on [Orangesoft Tech](https://orangesoft.co/blog/how-to-make-proxy-balancing-in-guzzle). +Read more about load balancing on [Sam Rose's blog](https://samwho.dev/load-balancing/). diff --git a/benchmarks/FrequencyRandomBench.php b/benchmarks/FrequencyRandomBench.php index 4b7e47b..d7f9717 100644 --- a/benchmarks/FrequencyRandomBench.php +++ b/benchmarks/FrequencyRandomBench.php @@ -4,37 +4,33 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\FrequencyRandomStrategy; -use Orangesoft\Throttler\Throttler; +use Orangesoft\Throttler\FrequencyRandomThrottler; use Orangesoft\Throttler\ThrottlerInterface; -class FrequencyRandomBench +final class FrequencyRandomBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), ]); - $this->throttler = new Throttler( - new FrequencyRandomStrategy(), - ); + $this->throttler = new FrequencyRandomThrottler(); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchFrequencyRandom(): void + public function benchFrequencyRandomAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/benchmarks/RandomBench.php b/benchmarks/RandomBench.php index 8ea5b62..571b5dd 100644 --- a/benchmarks/RandomBench.php +++ b/benchmarks/RandomBench.php @@ -4,37 +4,33 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\RandomStrategy; -use Orangesoft\Throttler\Throttler; +use Orangesoft\Throttler\RandomThrottler; use Orangesoft\Throttler\ThrottlerInterface; -class RandomBench +final class RandomBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), ]); - $this->throttler = new Throttler( - new RandomStrategy(), - ); + $this->throttler = new RandomThrottler(); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchRandom(): void + public function benchRandomAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/benchmarks/RoundRobinBench.php b/benchmarks/RoundRobinBench.php index a6deaa8..f4fde43 100644 --- a/benchmarks/RoundRobinBench.php +++ b/benchmarks/RoundRobinBench.php @@ -4,40 +4,34 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\InMemoryCounter; -use Orangesoft\Throttler\Strategy\RoundRobinStrategy; -use Orangesoft\Throttler\Throttler; +use Orangesoft\Throttler\Counter\InMemoryCounter; +use Orangesoft\Throttler\RoundRobinThrottler; use Orangesoft\Throttler\ThrottlerInterface; -class RoundRobinBench +final class RoundRobinBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), ]); - $this->throttler = new Throttler( - new RoundRobinStrategy( - new InMemoryCounter(start: 0), - ) - ); + $this->throttler = new RoundRobinThrottler(new InMemoryCounter()); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchRoundRobin(): void + public function benchRoundRobinAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/benchmarks/SmoothWeightedRoundRobinBench.php b/benchmarks/SmoothWeightedRoundRobinBench.php index 896d7dc..148617c 100644 --- a/benchmarks/SmoothWeightedRoundRobinBench.php +++ b/benchmarks/SmoothWeightedRoundRobinBench.php @@ -4,37 +4,33 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\SmoothWeightedRoundRobinStrategy; -use Orangesoft\Throttler\Throttler; +use Orangesoft\Throttler\SmoothWeightedRoundRobinThrottler; use Orangesoft\Throttler\ThrottlerInterface; -class SmoothWeightedRoundRobinBench +final class SmoothWeightedRoundRobinBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1', 5), + new Node('192.168.0.2', 1), + new Node('192.168.0.3', 1), ]); - $this->throttler = new Throttler( - new SmoothWeightedRoundRobinStrategy(), - ); + $this->throttler = new SmoothWeightedRoundRobinThrottler(); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchSmoothWeightedRoundRobin(): void + public function benchSmoothWeightedRoundRobinAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/benchmarks/WeightedRandomBench.php b/benchmarks/WeightedRandomBench.php index ac0a0a1..a242ca4 100644 --- a/benchmarks/WeightedRandomBench.php +++ b/benchmarks/WeightedRandomBench.php @@ -4,37 +4,33 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\WeightedRandomStrategy; -use Orangesoft\Throttler\Throttler; use Orangesoft\Throttler\ThrottlerInterface; +use Orangesoft\Throttler\WeightedRandomThrottler; -class WeightedRandomBench +final class WeightedRandomBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1', 5), + new Node('192.168.0.2', 1), + new Node('192.168.0.3', 1), ]); - $this->throttler = new Throttler( - new WeightedRandomStrategy(), - ); + $this->throttler = new WeightedRandomThrottler(); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchWeightedRandom(): void + public function benchWeightedRandomAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/benchmarks/WeightedRoundRobinBench.php b/benchmarks/WeightedRoundRobinBench.php index 9a2ee6e..8b99314 100644 --- a/benchmarks/WeightedRoundRobinBench.php +++ b/benchmarks/WeightedRoundRobinBench.php @@ -4,40 +4,34 @@ namespace Orangesoft\Throttler\Benchmarks; -use Orangesoft\Throttler\Collection\Collection; use Orangesoft\Throttler\Collection\CollectionInterface; +use Orangesoft\Throttler\Collection\InMemoryCollection; use Orangesoft\Throttler\Collection\Node; -use Orangesoft\Throttler\Strategy\InMemoryCounter; -use Orangesoft\Throttler\Strategy\WeightedRoundRobinStrategy; -use Orangesoft\Throttler\Throttler; +use Orangesoft\Throttler\Counter\InMemoryCounter; use Orangesoft\Throttler\ThrottlerInterface; +use Orangesoft\Throttler\WeightedRoundRobinThrottler; -class WeightedRoundRobinBench +final class WeightedRoundRobinBench { private CollectionInterface $collection; private ThrottlerInterface $throttler; public function __construct() { - $this->collection = new Collection([ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), + $this->collection = new InMemoryCollection([ + new Node('192.168.0.1', 5), + new Node('192.168.0.2', 1), + new Node('192.168.0.3', 1), ]); - $this->throttler = new Throttler( - new WeightedRoundRobinStrategy( - new InMemoryCounter(start: 0), - ) - ); + $this->throttler = new WeightedRoundRobinThrottler(new InMemoryCounter()); } /** * @Revs(1000) - * * @Iterations(5) */ - public function benchWeightedRoundRobin(): void + public function benchWeightedRoundRobinAlgorithm(): void { $this->throttler->pick($this->collection); } diff --git a/composer.json b/composer.json index eb0a3d6..52ec204 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,10 @@ "autoload": { "psr-4": { "Orangesoft\\Throttler\\": "./src/" - } + }, + "files": [ + "./helpers.php" + ] }, "autoload-dev": { "psr-4": { @@ -47,17 +50,20 @@ } }, "scripts": { - "phpbench": "vendor/bin/phpbench run --report=throttler --ansi", + "phpbench": "./vendor/bin/phpbench run --report=throttler --ansi", "phpunit": "./vendor/bin/phpunit --verbose --colors=always --no-coverage", + "phpunit:clear-cache": "rm ./build/cache/phpunit.cache", "phpunit-coverage": "./vendor/bin/phpunit --verbose --colors=always --coverage-text", "phpunit-coverage-html": "./vendor/bin/phpunit --verbose --colors=always --coverage-html ./build/logs/phpunit-coverage/", "parallel-lint": "./vendor/bin/parallel-lint --colors ./src/ ./tests/ ./benchmarks/", "php-cs-fixer:fix": "./vendor/bin/php-cs-fixer fix --verbose --ansi --show-progress=dots", "php-cs-fixer:diff": "./vendor/bin/php-cs-fixer fix --verbose --ansi --dry-run --diff", + "php-cs-fixer:clear-cache": "rm ./build/cache/php-cs-fixer.cache", "psalm": "./vendor/bin/psalm --show-info=true", - "psalm:set-baseline": "@psalm --set-baseline=./psalm-baseline.xml", - "psalm:update-baseline": "@psalm --update-baseline", - "psalm:ignore-baseline": "@psalm --ignore-baseline", + "psalm:clear-cache": "rm -rf ./build/cache/psalm/", + "psalm:set-baseline": "@psalm --set-baseline=./psalm-baseline.xml --no-cache", + "psalm:update-baseline": "@psalm --update-baseline --no-cache", + "psalm:ignore-baseline": "@psalm --ignore-baseline --no-cache", "test": [ "@parallel-lint", "@psalm", diff --git a/docs/index.md b/docs/index.md index e07aded..4b628cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,329 +1,707 @@ # Documentation -- [Configure Throttler](#configure-throttler) - [Available strategies](#available-strategies) -- [Sort nodes](#sort-nodes) -- [Keep counter](#keep-counter) -- [Serialize strategies](#serialize-strategies) -- [Dynamically change strategy](#dynamically-change-strategy) + - [Random](#random) + - [Weighted random](#weighted-random) + - [Frequency random](#frequency-random) + - [Round-robin](#round-robin) + - [Weighted round-robin](#weighted-round-robin) + - [Smooth weighted round-robin](#smooth-weighted-round-robin) +- [Keep states](#keep-states) + - [Use counting](#use-counting) + - [Use serialization](#use-serialization) +- [Custom counter](#custom-counter) +- [Custom strategy](#custom-strategy) +- [Multiple throttler](#multiple-throttler) - [Balance cluster](#balance-cluster) +- [Guzzle middleware](#guzzle-middleware) -## Configure Throttler +## Available strategies + +The following strategies are available: -You need to choose a strategy and configure it: +### Random + +Random is a strategy where each node has an equal probability of being chosen, regardless of previous selections or the order of nodes. Use [Orangesoft\Throttler\RandomThrottler](../src/RandomThrottler.php) as shown below: ```php pick($collection); + + // ... +} ``` -To get next Node call the `next()` method with passed collection: +See a visualization of the random strategy's output: + +```text ++---------+-------------+--------+ +| request | node | chance | ++---------+-------------+--------+ +| 1 | 192.168.0.1 | 25.0% | +| 2 | 192.168.0.2 | 25.0% | +| 3 | 192.168.0.3 | 25.0% | +| 4 | 192.168.0.4 | 25.0% | +| n | etc. | | ++---------+-------------+--------+ +``` + +### Weighted random + +Weighted random is a sort of random strategy where the probability of selecting each node is proportional to its assigned weight, allowing some nodes to have a higher chance of being chosen than others. Use [Orangesoft\Throttler\WeightedRandomThrottler](../src/WeightedRandomThrottler.php) as shown below: ```php +pick($collection); - + // ... } ``` -As a result, you will see the following distribution of nodes: +See a visualization of the weighted random strategy's output: ```text -+-------+ -| node1 | -| node1 | -| node1 | -| node1 | -| node1 | -| node2 | -| node3 | -| etc. | -+-------+ ++---------+-------------+--------+ +| request | node | chance | ++---------+-------------+--------+ +| 1 | 192.168.0.1 | 62.5% | +| 2 | 192.168.0.2 | 12.5% | +| 3 | 192.168.0.3 | 12.5% | +| 4 | 192.168.0.4 | 12.5% | +| n | etc. | | ++---------+-------------+--------+ ``` -Result of Throttler depends on the chosen strategy. +### Frequency random -## Available strategies +Frequency random is a strategy that allows selecting nodes with a specific frequency for a certain depth of the collection. For example, a threshold of `0.2` represents 20% of the nodes from their total length, and a frequency of `0.8` means there's an 80% probability that the first 20% of nodes will be picked. Nodes are sorted by their weight or provided in the order they were added to the collection. Use [Orangesoft\Throttler\FrequencyRandomThrottler](../src/FrequencyRandomThrottler.php) as shown below: -Strategies are divided into two types: random and round-robin. The following strategies are available: +```php +pick($collection); -## Sort nodes + // ... +} +``` -For some strategies, such as [FrequencyRandomStrategy](../src/Strategy/FrequencyRandomStrategy.php), it might be necessary to adjust the order of nodes by their weight. This can be done with Sorter: +See a visualization of the frequency random strategy's output: + +```text ++----------+--------------+--------+ +| request | node | chance | ++----------+--------------+--------+ +| 1 | 192.168.0.10 | 40.0% | +| 2 | 192.168.0.9 | 40.9% | ++----------+--------------+--------+ +| 3 | 192.168.0.8 | 2.5% | +| 4 | 192.168.0.7 | 2.5% | +| 5 | 192.168.0.6 | 2.5% | +| 6 | 192.168.0.5 | 2.5% | +| 7 | 192.168.0.4 | 2.5% | +| 8 | 192.168.0.3 | 2.5% | +| 9 | 192.168.0.2 | 2.5% | +| 10 | 192.168.0.1 | 2.5% | +| n | etc. | | ++----------+--------------+--------+ +``` + +### Round-robin + +Round-robin is a strategy in which nodes in a collection are processed cyclically and sequentially, with equal priority. Use [Orangesoft\Throttler\RoundRobinThrottler](../src/RoundRobinThrottler.php) as shown below: ```php pick($collection); -$sorter->sort($collection, new Desc()); + // ... +} ``` -The nodes at the top of the list will be used more often. You can manage sorting using [Asc](../src/Collection/Asc.php) and [Desc](../src/Collection/Desc.php) comparators. Example for the Desc direction: +See a visualization of the round-robin strategy's output: ```text -+--------+--------+ -| name | weight | -+--------+--------+ -| node7 | 2048 | -| node4 | 1024 | -| node3 | 512 | -| node8 | 256 | -| node5 | 128 | -| node6 | 64 | -| node1 | 32 | -| node2 | 16 | -| node9 | 8 | -| node10 | 4 | -+--------+--------+ ++---------+-------------+ +| request | node | ++---------+-------------+ +| 1 | 192.168.0.1 | +| 2 | 192.168.0.2 | +| 3 | 192.168.0.3 | +| 4 | 192.168.0.4 | +| n | etc. | ++---------+-------------+ ``` -FrequencyRandomStrategy has 2 not required options: frequency and depth. Frequency is probability to choose nodes from a first group in percent. Depth is length the first group from the list in percent. By default, frequency is 0.8 and depth is 0.2: +### Weighted round-robin + +Weighted round-robin is a modification of the round-robin strategy, where each node is assigned a weight that determines its priority or frequency of selection in the distribution cycle. Use [Orangesoft\Throttler\WeightedRoundRobinThrottler](../src/WeightedRoundRobinThrottler.php) as shown below: ```php -$throttler = new Throttler( - new FrequencyRandomStrategy(frequency: 0.8, depth: 0.2), +pick($collection); +$collection = new InMemoryCollection([ + new Node('192.168.0.1', 5), + new Node('192.168.0.2', 1), + new Node('192.168.0.3', 1), + new Node('192.168.0.4', 1), +]); + +while (true) { + /** @var NodeInterface $node */ + $node = $throttler->pick($collection); + + // ... +} ``` -The probability of choosing nodes for FrequencyRandomStrategy can be visualized as follows: +See a visualization of the weighted round-robin strategy's output: ```text -+--------+--------+ -| nodes | chance | -+--------+--------+ -| node7 | 40% | -| node4 | 40% | -+--------+--------+ -| node3 | 2.5% | -| node8 | 2.5% | -| node5 | 2.5% | -| node6 | 2.5% | -| node1 | 2.5% | -| node2 | 2.5% | -| node9 | 2.5% | -| node10 | 2.5% | -+--------+--------+ ++---------+-------------+ +| request | node | ++---------+-------------+ +| 1 | 192.168.0.1 | +| 2 | 192.168.0.1 | +| 3 | 192.168.0.1 | +| 4 | 192.168.0.1 | +| 5 | 192.168.0.1 | +| 6 | 192.168.0.2 | +| 7 | 192.168.0.3 | +| 8 | 192.168.0.4 | +| n | etc. | ++---------+-------------+ ``` -If you need the reverse order of the nodes use Asc direction. - -## Keep counter +### Smooth weighted round-robin -For strategies are [RoundRobinStrategy](../src/Strategy/RoundRobinStrategy.php) and [WeightedRoundRobinStrategy](../src/Strategy/WeightedRoundRobinStrategy.php) you must use InMemoryCounter to remember order of nodes. A counter is not needed for round-robin strategies: +Smooth weighted round-robin is an improved version of weighted round-robin that provides a more even distribution of load among nodes with different weights, minimizing fluctuations in the selection of elements. Use [Orangesoft\Throttler\SmoothWeightedRoundRobinThrottler](../src/SmoothWeightedRoundRobinThrottler.php) as shown below: ```php pick($collection); + + // ... +} +``` + +See a visualization of the smooth weighted round-robin strategy's output: + +```text ++---------+-------------+ +| request | node | ++---------+-------------+ +| 1 | 192.168.0.1 | +| 2 | 192.168.0.1 | +| 3 | 192.168.0.2 | +| 4 | 192.168.0.1 | +| 5 | 192.168.0.3 | +| 6 | 192.168.0.1 | +| 7 | 192.168.0.4 | +| 8 | 192.168.0.1 | +| n | etc. | ++---------+-------------+ +``` + +## Keep states + +Load balancing strategies can be of 2 types: *random-based* and *round-robin based*. Random-based strategies don't support keeping states between calls in different processes, as each request is based on probability. Round-robin based strategies support keeping states through a counting or serialization: + +```text ++-----------------------------+---------------+ +| Strategy | Method | ++-----------------------------+---------------+ +| random | [x] | +| weighted random | [x] | +| frequency random | [x] | +| round-robin | counting | +| weighted round-robin | counting | +| smooth weighted round-robin | serialization | ++-----------------------------+---------------+ ``` -You can replace InMemoryCounter if you need to keep the order of these strategies between PHP calls, for example, in queues. Just to implement [CounterInterface](../src/Strategy/CounterInterface.php): +This is especially useful when it's necessary to resume work precisely from where the previous process ended. + +### Use counting + +For round-robin and weighted round-robin strategies, the counter `Orangesoft\Throttler\Counter\InMemoryCounter::class` is available, which stores the request count in memory: ```php -class RedisCounter implements CounterInterface -{ - public function __construct( - private Client $client, - ) { - } +client->exists($name)) { - $this->client->set($name, -1); - } +use Orangesoft\Throttler\RoundRobinThrottler; +use Orangesoft\Throttler\Counter\InMemoryCounter; +use Orangesoft\Throttler\Collection\InMemoryCollection; - return $this->client->incr($name); - } +$counter = 0; + +$throttler = new RoundRobinThrottler( + new InMemoryCounter( + start: $counter, + ), +) + +$collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + new Node('192.168.0.4'), +]); + +while (true) { + /** @var NodeInterface $node */ + $node = $throttler->pick($collection); + + // ... + + $counter++; } ``` -In the example above, we wrote the counter with Redis. +You can save the current request count in any storage and resume work from the last iteration as shown below: ```php -/** @var Predis\Client $client */ +pick($collection); + + // ... +} /** @var string $serialized */ -$serialized = serialize($strategy); +$serialized = serialize($throttler); ``` -The serialization result will return an instance of SmoothWeightedRoundRobinStrategy with the actual weights for the nodes: +You can save the serialization result in any storage and restore the strategy's operation using the `unserialize(string $data, array $options = []): mixed` function. The serialization result will return an instance of `Orangesoft\Throttler\SmoothWeightedRoundRobinThrottler::class` with the actual weights for the nodes: ```php -/** @var SmoothWeightedRoundRobinStrategy $strategy */ -$strategy = unserialize($serialized); +/** @var SmoothWeightedRoundRobinThrottler $throttler */ +$throttler = unserialize($serialized); + +while (true) { + /** @var NodeInterface $node */ + $node = $throttler->pick($collection); + + // ... +} ``` -This way you can preserve the order of nodes for a given strategy between PHP calls. +This way keep state the order of nodes for a given strategy between PHP calls. -## Dynamically change strategy +## Custom counter -You can dynamically change the strategy from the client code. To do this, configure the [MultipleDynamicStrategy](../src/Strategy/MultipleDynamicStrategy.php) with the strategies you need: +To create a custom counter for *round-robin based* strategies, for example, using Redis, you need to implement the `Orangesoft\Throttler\Counter\CounterInterface::next(string $name = 'default'): int` interface as shown below: ```php $context + */ + public function pick(CollectionInterface $collection, array $context = []) : NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); + } + + // ... + } +}; ``` -Pass the `strategy_name` parameter through the context with the name of the strategy class according to which the collection needs to be balanced: +With your own strategies, you can wrap existing ones and, for example, cache their behavior. + +## Multiple throttler + +To dynamically change strategies from client code, use `Orangesoft\Throttler\MultipleThrottler::class` after pre-configuring it with preferred strategies: ```php -/** @var Node $node */ -$node = $throttler->pick($collection, [ - 'strategy_name' => RoundRobinStrategy::class, +pick( + collection: $collection, + context: [ + 'throttler' => RoundRobinStrategy::class, + 'counter' => InMemoryCounter::class, + ], +); +``` + +The context parameter `throttler` specifies the class of the strategy to be accessed, while `counter` sets the name for the counter, which will be passed to the `Orangesoft\Throttler\Counter\CounterInterface::next(string $name = 'default'): int` method to avoid conflicts between strategies. ## Balance cluster -You can divide the nodes into clusters and set a specific balancing strategy for each cluster. To do this, configure the [ClusterDetermineStrategy](../src/Strategy/ClusterDetermineStrategy.php) as shown below: +You can add specific node collections to clusters and run the load balancer only for a specific cluster. Configure `Orangesoft\Throttler\Cluster\ClusterPool::class`, where you need to bind the desired strategies to the cluster name, and create the required number of clusters `Orangesoft\Throttler\Cluster\Cluster::class`: ```php balance( + pool: $pool, + context: [ + 'counter' => 'Mercury', + ], ); ``` -Create clusters from nodes: +Note that you can also pass an optional context parameter `counter` with the counter name to avoid conflicts between clusters that use *round-robin based* strategies. + +## Guzzle middleware + +Let's break down an example of how to configure Guzzle for proxy balancing using middleware, which allows hiding a real IP server. To install the necessary packages to demonstrate proxy balancing in Guzzle, let's use the [Composer](https://getcomposer.org/) package manager: + +```text +composer require \ + && orangesoft/throttler \ + && guzzlehttp/guzzle \ + && psr/http-message \ + && predis/predis +``` + +The package [guzzlehttp/guzzle](https://github.com/guzzle/guzzle) is necessary for HTTP requests, [psr/http-message](https://github.com/php-fig/http-message) — HTTP message interfaces, [predis/predis](https://github.com/predis/predis) — for saving balancing strategies between the callings of PHP processes. + +Write proxy middleware for Guzzle that will add the proxy to every HTTP-requests according to the chosen strategy: ```php -$collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), + $context + */ + public function __construct( + private ThrottlerInterface $throttler, + private CollectionInterface $collection, + private array $context = [], + ) { + } + + public function __invoke(callable $handler): \Closure + { + return function (RequestInterface $request, array $options) use ($handler): ResponseInterface { + /** @var NodeInterface $node */ + $node = $this->throttler->pick($this->collection, $this->context); + + $options['proxy'] = $node->getName(); + + return $handler($request, $options); + }; + } +} +``` + +Create simple in-memory storage in Redis to keep load balancing counting between PHP calls for *round-robin based* strategies. Below is an example of how to implement the in-memory counter with the help of the `predis/predis` package: + +```php +redis->exists($name)) { + $this->redis->set($name, -1); + } + + return $this->redis->incr($name); + } +} +``` + +Now it’s time to configure load balancer and connect proxy middleware to Guzzle: + +```php +push(new ProxyMiddleware($throttler, $collection)); +$guzzle = new GuzzleClient(['handler' => $stack]); ``` -Using the `balance()` method, force your cluster to balance: +We can use Guzzle as always: ```php -/** @var Node $node */ -$node = $cluster->balance($throttler); +while (true) { + /** @var ResponseInterface $response */ + $response = $guzzle->get('https://httpbin.org/ip'); + + // ... +} +``` + +The result of the proxy balancing will be as follows: + +```text ++---------+-----------------------+ +| request | proxy | ++---------+-----------------------+ +| 1 | user:pass@192.168.0.1 | +| 2 | user:pass@192.168.0.1 | +| 3 | user:pass@192.168.0.1 | +| 4 | user:pass@192.168.0.1 | +| 5 | user:pass@192.168.0.1 | +| 6 | user:pass@192.168.0.2 | +| 7 | user:pass@192.168.0.3 | +| 8 | user:pass@192.168.0.4 | +| n | etc. | ++---------+-----------------------+ ``` -This method is well suited in cases where the nodes can be divided according to a specific criterion and each cluster needs its own balancing strategy. +Proxy balancing in Guzzle is one of the package's use cases. You can use it for distributing requests across different microservices to ensure even utilization and prevent bottlenecks, read-only database queries across multiple database servers to improve performance, API requests across multiple backend services to ensure high availability and fault tolerance, etc. diff --git a/helpers.php b/helpers.php new file mode 100644 index 0000000..da32a72 --- /dev/null +++ b/helpers.php @@ -0,0 +1,21 @@ + - - - - iterator_to_array($this->nodes, false) - - - $node - - - $node - - - Node[] - - - - - $callback - - - - - $context['cluster_name'] - - - $this->clusterNames[$context['cluster_name']] - $this->clusterNames[$context['cluster_name']] - - - $this->clusterNames - $this->strategies - - - StrategyInterface - - - - - $context['strategy_name'] - - - StrategyInterface - - - - - $context['counter_name'] ?? self::class - - - - - $this->currentWeights - - - getNode - - - - - getNode - - - Node - - - $collection->getNode($index) - - - - - $context['counter_name'] ?? self::class - - - - - assertIsIterable - - - + diff --git a/src/Cluster/Cluster.php b/src/Cluster/Cluster.php new file mode 100644 index 0000000..4da6fcd --- /dev/null +++ b/src/Cluster/Cluster.php @@ -0,0 +1,28 @@ + $context + */ + public function balance(ThrottlerInterface $pool, array $context = []): NodeInterface + { + return $pool->pick($this->collection, array_merge($context, [ + 'cluster' => $this->name, + ])); + } +} diff --git a/src/Cluster/ClusterInterface.php b/src/Cluster/ClusterInterface.php new file mode 100644 index 0000000..dd989e2 --- /dev/null +++ b/src/Cluster/ClusterInterface.php @@ -0,0 +1,16 @@ + $context + */ + public function balance(ThrottlerInterface $pool, array $context = []): NodeInterface; +} diff --git a/src/Cluster/ClusterPool.php b/src/Cluster/ClusterPool.php new file mode 100644 index 0000000..6b98c0f --- /dev/null +++ b/src/Cluster/ClusterPool.php @@ -0,0 +1,56 @@ + + */ + private array $throttlers = []; + /** + * @var array + */ + private array $clusterNames = []; + + public function __construct(ClusterSet ...$clusterSets) + { + foreach ($clusterSets as $clusterSet) { + $id = $clusterSet->throttler::class; + $this->throttlers[$id] = $clusterSet->throttler; + + foreach ($clusterSet->clusterNames as $clusterName) { + if (isset($this->clusterNames[$clusterName])) { + throw new \UnexpectedValueException(sprintf('The cluster "%s" has already been added.', $clusterName)); // @codeCoverageIgnore + } + + $this->clusterNames[$clusterName] = $id; + } + } + } + + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if (!isset($context['cluster'])) { + throw new \RuntimeException('Required parameter "cluster" is missing.'); // @codeCoverageIgnore + } + + if (!\is_string($context['cluster'])) { + throw new \RuntimeException(sprintf('The parameter "cluster" must be as a string, %s given.', get_debug_type($context['cluster']))); // @codeCoverageIgnore + } + + if (!isset($this->clusterNames[$context['cluster']])) { + throw new \RuntimeException(sprintf('The cluster "%s" is undefined.', $context['cluster'])); // @codeCoverageIgnore + } + + $throttler = $this->throttlers[$this->clusterNames[$context['cluster']]]; + + return $throttler->pick($collection, $context); + } +} diff --git a/src/Cluster/ClusterSet.php b/src/Cluster/ClusterSet.php new file mode 100644 index 0000000..2873d8a --- /dev/null +++ b/src/Cluster/ClusterSet.php @@ -0,0 +1,22 @@ +weight - $b->weight; - } -} diff --git a/src/Collection/Cluster.php b/src/Collection/Cluster.php deleted file mode 100644 index b4497fd..0000000 --- a/src/Collection/Cluster.php +++ /dev/null @@ -1,23 +0,0 @@ -pick($this->collection, array_merge($context, [ - 'cluster_name' => $this->name, - ])); - } -} diff --git a/src/Collection/ClusterInterface.php b/src/Collection/ClusterInterface.php deleted file mode 100644 index 1cd6a41..0000000 --- a/src/Collection/ClusterInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -nodes = new \SplObjectStorage(); - - foreach ($nodes as $node) { - $this->addNode($node); - } - } - - public function addNode(Node $node): self - { - if (0 >= $node->weight) { - $this->isWeighted = false; - } - - $this->nodes->attach($node); - - return $this; - } - - public function getNode(int $index): Node - { - if ($index > $this->nodes->count()) { - throw new \InvalidArgumentException(sprintf('Cannot find node at index "%d".', $index)); - } - - $this->nodes->rewind(); - - while ($index--) { - $this->nodes->next(); - } - - /** @var Node $node */ - $node = $this->nodes->current(); - - return $node; - } - - public function hasNode(Node $node): bool - { - return $this->nodes->contains($node); - } - - public function removeNode(Node $node): void - { - $this->nodes->detach($node); - } - - public function purge(): void - { - $this->nodes->removeAll($this->nodes); - } - - public function isWeighted(): bool - { - return $this->isWeighted; - } - - public function isEmpty(): bool - { - return 0 === $this->nodes->count(); - } - - public function count(): int - { - return $this->nodes->count(); - } - - /** - * @return Node[] - */ - public function toArray(): array - { - return iterator_to_array($this->nodes, false); - } - - public function getIterator(): \Traversable - { - return $this->nodes; - } -} diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index aaec000..095f40f 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -4,24 +4,29 @@ namespace Orangesoft\Throttler\Collection; +/** + * @template T of NodeInterface + * @template-extends \IteratorAggregate + */ interface CollectionInterface extends \Countable, \IteratorAggregate { - public function addNode(Node $node): self; + public function add(NodeInterface $node): self; - public function getNode(int $index): Node; + public function get(int $key): NodeInterface; - public function hasNode(Node $node): bool; + public function has(NodeInterface $node): bool; - public function removeNode(Node $node): void; + public function remove(NodeInterface $node): self; - public function purge(): void; - - public function isWeighted(): bool; + /** + * @param callable(T, T): int $callback + */ + public function sort(callable $callback): self; public function isEmpty(): bool; /** - * @return Node[] + * @return array */ public function toArray(): array; } diff --git a/src/Collection/Desc.php b/src/Collection/Desc.php deleted file mode 100644 index 7fb7083..0000000 --- a/src/Collection/Desc.php +++ /dev/null @@ -1,13 +0,0 @@ -weight - $a->weight; - } -} diff --git a/src/Collection/Exception/EmptyCollectionException.php b/src/Collection/Exception/EmptyCollectionException.php deleted file mode 100644 index 95c9df3..0000000 --- a/src/Collection/Exception/EmptyCollectionException.php +++ /dev/null @@ -1,13 +0,0 @@ - + */ + private array $nodes; + /** + * @var array + */ + private array $keys; + + /** + * @param NodeInterface[] $nodes + */ + public function __construct(array $nodes = []) + { + $this->nodes = []; + $this->keys = []; + + foreach ($nodes as $node) { + if ($this->has($node)) { + throw new \InvalidArgumentException(sprintf('All nodes must be unique, "%s" given as duplicate.', $node->getName())); + } + + $this->nodes[] = $node; + $this->keys[$node->getName()] = array_key_last($this->nodes); + } + } + + public function add(NodeInterface $node): self + { + if ($this->has($node)) { + throw new \UnexpectedValueException(sprintf('The node "%s" has been already added.', $node->getName())); + } + + $self = clone $this; + $self->nodes[] = $node; + $self->keys[$node->getName()] = array_key_last($self->nodes); + + return $self; + } + + public function get(int $key): NodeInterface + { + if (!isset($this->nodes[$key])) { + throw new \OutOfRangeException(sprintf('Can\'t get node at key "%d".', $key)); + } + + return $this->nodes[$key]; + } + + public function has(NodeInterface $node): bool + { + return \array_key_exists($node->getName(), $this->keys); + } + + public function remove(NodeInterface $node): self + { + if (!$this->has($node)) { + throw new \UnexpectedValueException(sprintf('The node "%s" hasn\'t been already added.', $node->getName())); + } + + $self = clone $this; + unset($self->nodes[$self->keys[$node->getName()]]); + + return new self($self->toArray()); + } + + /** + * @param callable(T, T): int $callback + */ + public function sort(callable $callback): self + { + $nodes = $this->toArray(); + + /** @psalm-suppress InvalidArgument */ + usort($nodes, $callback); + + return new self($nodes); + } + + public function isEmpty(): bool + { + return 0 === $this->count(); + } + + public function count(): int + { + return \count($this->nodes); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->nodes; + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->nodes); + } +} diff --git a/src/Collection/Node.php b/src/Collection/Node.php index 96dccd0..52d2d82 100644 --- a/src/Collection/Node.php +++ b/src/Collection/Node.php @@ -4,12 +4,33 @@ namespace Orangesoft\Throttler\Collection; -final class Node +final class Node implements NodeInterface { + /** + * @param array $payload + */ public function __construct( - public readonly string $name, - public readonly int $weight = 0, - public readonly array $info = [], + private string $name, + private int $weight = 0, + private array $payload = [], ) { } + + public function getName(): string + { + return $this->name; + } + + public function getWeight(): int + { + return $this->weight; + } + + /** + * @return array + */ + public function getPayload(): array + { + return $this->payload; + } } diff --git a/src/Collection/NodeInterface.php b/src/Collection/NodeInterface.php new file mode 100644 index 0000000..682956c --- /dev/null +++ b/src/Collection/NodeInterface.php @@ -0,0 +1,17 @@ + + */ + public function getPayload(): array; +} diff --git a/src/Collection/Sort/Asc.php b/src/Collection/Sort/Asc.php new file mode 100644 index 0000000..431032d --- /dev/null +++ b/src/Collection/Sort/Asc.php @@ -0,0 +1,15 @@ +getWeight() - $b->getWeight(); + } +} diff --git a/src/Collection/Sort/Desc.php b/src/Collection/Sort/Desc.php new file mode 100644 index 0000000..b00f40f --- /dev/null +++ b/src/Collection/Sort/Desc.php @@ -0,0 +1,15 @@ +getWeight() - $a->getWeight(); + } +} diff --git a/src/Collection/Sorter.php b/src/Collection/Sorter.php deleted file mode 100644 index 87e73a8..0000000 --- a/src/Collection/Sorter.php +++ /dev/null @@ -1,21 +0,0 @@ -toArray(); - - usort($nodes, $callback); - - $collection->purge(); - - foreach ($nodes as $node) { - $collection->addNode($node); - } - } -} diff --git a/src/Strategy/CounterInterface.php b/src/Counter/CounterInterface.php similarity index 74% rename from src/Strategy/CounterInterface.php rename to src/Counter/CounterInterface.php index f27c6f1..2b85d40 100644 --- a/src/Strategy/CounterInterface.php +++ b/src/Counter/CounterInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Orangesoft\Throttler\Strategy; +namespace Orangesoft\Throttler\Counter; interface CounterInterface { diff --git a/src/Strategy/InMemoryCounter.php b/src/Counter/InMemoryCounter.php similarity index 76% rename from src/Strategy/InMemoryCounter.php rename to src/Counter/InMemoryCounter.php index 3cd9576..2ebdcdc 100644 --- a/src/Strategy/InMemoryCounter.php +++ b/src/Counter/InMemoryCounter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Orangesoft\Throttler\Strategy; +namespace Orangesoft\Throttler\Counter; final class InMemoryCounter implements CounterInterface { @@ -22,6 +22,9 @@ public function next(string $name = 'default'): int $this->counter[$name] = $this->start; } - return $this->counter[$name]++; + $next = $this->counter[$name]; + ++$this->counter[$name]; + + return $next; } } diff --git a/src/FrequencyRandomThrottler.php b/src/FrequencyRandomThrottler.php new file mode 100644 index 0000000..c98185a --- /dev/null +++ b/src/FrequencyRandomThrottler.php @@ -0,0 +1,37 @@ + $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + $sorted = $collection->sort(new Desc()); + $total = \count($sorted); + $lowerKey = (int) ceil($this->threshold * $total); + $higherKey = $lowerKey + (1 < $total ? 1 : 0); + $probability = mt_rand() / mt_getrandmax(); + $key = $this->frequency >= $probability ? mt_rand(1, $lowerKey) : mt_rand($higherKey, $total); + + return $sorted->get($key - 1); + } +} diff --git a/src/MultipleThrottler.php b/src/MultipleThrottler.php new file mode 100644 index 0000000..28bf7f8 --- /dev/null +++ b/src/MultipleThrottler.php @@ -0,0 +1,45 @@ + + */ + private array $throttlers = []; + + public function __construct(ThrottlerInterface ...$throttlers) + { + foreach ($throttlers as $throttler) { + $this->throttlers[$throttler::class] = $throttler; + } + } + + /** + * @param array $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if (!isset($context['throttler']) || !\is_string($context['throttler'])) { + throw new \RuntimeException('The required parameter "throttler" must be passed as a throttler\'s class name.'); // @codeCoverageIgnore + } + + if (!isset($this->throttlers[$context['throttler']])) { + throw new \RuntimeException(sprintf('The throttler "%s" is undefined.', $context['throttler'])); // @codeCoverageIgnore + } + + if (!class_exists($context['throttler']) || !is_a($context['throttler'], ThrottlerInterface::class, true)) { + throw new \UnexpectedValueException(sprintf('The throttler must be a class that exists and implements "%s" interface, "%s" given.', ThrottlerInterface::class, get_debug_type($context['throttler']))); // @codeCoverageIgnore + } + + $throttler = $this->throttlers[$context['throttler']]; + + return $throttler->pick($collection, $context); + } +} diff --git a/src/RandomThrottler.php b/src/RandomThrottler.php new file mode 100644 index 0000000..5c1bc49 --- /dev/null +++ b/src/RandomThrottler.php @@ -0,0 +1,25 @@ + $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + $key = mt_rand(0, \count($collection) - 1); + + return $collection->get($key); + } +} diff --git a/src/RoundRobinThrottler.php b/src/RoundRobinThrottler.php new file mode 100644 index 0000000..aa1e140 --- /dev/null +++ b/src/RoundRobinThrottler.php @@ -0,0 +1,36 @@ + $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + if (isset($context['counter']) && !\is_string($context['counter'])) { + throw new \RuntimeException(sprintf('The parameter "counter" must be as a string, %s given.', get_debug_type($context['counter']))); // @codeCoverageIgnore + } + + $counter = $context['counter'] ?? spl_object_hash($collection); + $key = $this->counter->next($counter) % \count($collection); + + return $collection->get($key); + } +} diff --git a/src/SmoothWeightedRoundRobinThrottler.php b/src/SmoothWeightedRoundRobinThrottler.php new file mode 100644 index 0000000..1c0d893 --- /dev/null +++ b/src/SmoothWeightedRoundRobinThrottler.php @@ -0,0 +1,70 @@ +> + */ + private array $weights = []; + /** + * @var array> + */ + private array $currentWeights = []; + + /** + * @param array $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + if (isset($context['counter']) && !\is_string($context['counter'])) { + throw new \RuntimeException(sprintf('The parameter "counter" must be as a string, %s given.', get_debug_type($context['counter']))); // @codeCoverageIgnore + } + + $counter = $context['counter'] ?? spl_object_hash($collection); + + if (!isset($this->weights[$counter]) || !isset($this->currentWeights[$counter])) { + $this->weights[$counter] = []; + $this->currentWeights[$counter] = []; + } + + if (0 === \count($this->weights[$counter]) || 0 === \count($this->currentWeights[$counter])) { + foreach ($collection as $key => $node) { + if (0 === $node->getWeight()) { + throw new \RuntimeException('All nodes in the collection must be weighted.'); // @codeCoverageIgnore + } + + $this->weights[$counter][$key] = $node->getWeight(); + $this->currentWeights[$counter][$key] = $node->getWeight(); + } + } + + if (0 === \count($this->currentWeights[$counter])) { + throw new \RuntimeException('Current weights are empty.'); // @codeCoverageIgnore + } + + $maxCurrentWeight = max($this->currentWeights[$counter]); + + if (false === $maxCurrentWeightKey = array_search($maxCurrentWeight, $this->currentWeights[$counter], true)) { + throw new \LogicException('Couldn\'t find max current weight index.'); // @codeCoverageIgnore + } + + $this->currentWeights[$counter][$maxCurrentWeightKey] -= array_sum($this->weights[$counter]); + + foreach ($this->weights[$counter] as $key => $weight) { + $this->currentWeights[$counter][$key] += $weight; + } + + return $collection->get($maxCurrentWeightKey); + } +} diff --git a/src/Strategy/ClusterDetermineStrategy.php b/src/Strategy/ClusterDetermineStrategy.php deleted file mode 100644 index b902779..0000000 --- a/src/Strategy/ClusterDetermineStrategy.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - private array $strategies = []; - /** - * @var array - */ - private array $clusterNames = []; - - public function __construct(ClusterSet ...$clusterSets) - { - foreach ($clusterSets as $key => $clusterSet) { - $this->strategies[$key] = $clusterSet->strategy; - - foreach ($clusterSet->clusterNames as $clusterName) { - if (isset($this->clusterNames[$clusterName])) { - throw new \InvalidArgumentException(sprintf('Cluster "%s" has already been added.', $clusterName)); - } - - $this->clusterNames[$clusterName] = $key; - } - } - } - - public function getNode(CollectionInterface $collection, array $context = []): Node - { - if (!isset($context['cluster_name'])) { - throw new \LogicException('Required parameter "cluster_name" is missing.'); - } - - if (!isset($this->clusterNames[$context['cluster_name']])) { - throw new \LogicException(sprintf('Cluster name "%s" is undefined.', $context['cluster_name'])); - } - - /** @var StrategyInterface $strategy */ - $strategy = $this->strategies[$this->clusterNames[$context['cluster_name']]]; - - return $strategy->getNode($collection, $context); - } -} diff --git a/src/Strategy/ClusterSet.php b/src/Strategy/ClusterSet.php deleted file mode 100644 index ca6b7b2..0000000 --- a/src/Strategy/ClusterSet.php +++ /dev/null @@ -1,20 +0,0 @@ -isEmpty()) { - throw new EmptyCollectionException(); - } - - $total = \count($collection); - $low = (int) ceil($this->depth * $total); - $high = $low + ((1 < $total) ? 1 : 0); - - $index = $this->isChance($this->frequency) ? mt_rand(1, $low) : mt_rand($high, $total); - - return $collection->getNode($index - 1); - } - - private function isChance(float $frequency): bool - { - return $frequency * 100 >= mt_rand(1, 100); - } -} diff --git a/src/Strategy/GcdCalculator.php b/src/Strategy/GcdCalculator.php deleted file mode 100644 index b17ab27..0000000 --- a/src/Strategy/GcdCalculator.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - private array $pool = []; - - public function __construct(StrategyInterface ...$strategies) - { - foreach ($strategies as $strategy) { - $this->pool[$strategy::class] = $strategy; - } - } - - public function getNode(CollectionInterface $collection, array $context = []): Node - { - if (!isset($context['strategy_name'])) { - throw new \LogicException('Required parameter "strategy_name" is missing.'); - } - - if (!class_exists($context['strategy_name']) || !is_a($context['strategy_name'], StrategyInterface::class, true)) { - throw new \LogicException(sprintf('Strategy must be a class that exists and implements "%s" interface, "%s" given.', StrategyInterface::class, get_debug_type($context['strategy_name']))); - } - - if (!isset($this->pool[$context['strategy_name']])) { - throw new \LogicException(sprintf('Strategy "%s" is undefined.', $context['strategy_name'])); - } - - /** @var StrategyInterface $strategy */ - $strategy = $this->pool[$context['strategy_name']]; - - return $strategy->getNode($collection, $context); - } -} diff --git a/src/Strategy/RandomStrategy.php b/src/Strategy/RandomStrategy.php deleted file mode 100644 index ff47033..0000000 --- a/src/Strategy/RandomStrategy.php +++ /dev/null @@ -1,23 +0,0 @@ -isEmpty()) { - throw new EmptyCollectionException(); - } - - $index = mt_rand(0, \count($collection) - 1); - - return $collection->getNode($index); - } -} diff --git a/src/Strategy/RoundRobinStrategy.php b/src/Strategy/RoundRobinStrategy.php deleted file mode 100644 index f7ec867..0000000 --- a/src/Strategy/RoundRobinStrategy.php +++ /dev/null @@ -1,28 +0,0 @@ -isEmpty()) { - throw new EmptyCollectionException(); - } - - $index = $this->counter->next($context['counter_name'] ?? self::class) % \count($collection); - - return $collection->getNode($index); - } -} diff --git a/src/Strategy/SmoothWeightedRoundRobinStrategy.php b/src/Strategy/SmoothWeightedRoundRobinStrategy.php deleted file mode 100644 index c31446e..0000000 --- a/src/Strategy/SmoothWeightedRoundRobinStrategy.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ - private array $weights = []; - /** - * @var array - */ - private array $currentWeights = []; - - public function getNode(CollectionInterface $collection, array $context = []): Node - { - if ($collection->isEmpty()) { - throw new EmptyCollectionException(); - } - - if (!$collection->isWeighted()) { - throw new UnweightedCollectionException(); - } - - if (0 === \count($this->weights) || 0 === \count($this->currentWeights)) { - /** @var array $collection */ - foreach ($collection as $index => $node) { - $this->weights[$index] = $this->currentWeights[$index] = $node->weight; - } - } - - $maxCurrentWeightIndex = $this->getMaxCurrentWeightIndex(); - - $this->recalculateCurrentWeights($maxCurrentWeightIndex); - - return $collection->getNode($maxCurrentWeightIndex); - } - - private function getMaxCurrentWeightIndex(): int - { - $maxCurrentWeight = max($this->currentWeights); - - if (false === $index = array_search($maxCurrentWeight, $this->currentWeights, true)) { - throw new \LogicException('Cannot find max current weight index.'); - } - - return $index; - } - - private function recalculateCurrentWeights(int $maxCurrentWeightIndex): void - { - $recalculatedCurrentWeight = $this->currentWeights[$maxCurrentWeightIndex] - array_sum($this->weights); - - $this->currentWeights[$maxCurrentWeightIndex] = $recalculatedCurrentWeight; - - foreach ($this->weights as $index => $weight) { - $this->currentWeights[$index] += $weight; - } - } -} diff --git a/src/Strategy/StrategyInterface.php b/src/Strategy/StrategyInterface.php deleted file mode 100644 index 1b11174..0000000 --- a/src/Strategy/StrategyInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -isEmpty()) { - throw new EmptyCollectionException(); - } - - if (!$collection->isWeighted()) { - throw new UnweightedCollectionException(); - } - - $currentWeight = 0; - - if (0 === $this->sumWeight) { - $this->sumWeight = $this->calculateSumWeight($collection); - } - - $randomWeight = mt_rand(1, $this->sumWeight); - - /** @var array $collection */ - foreach ($collection as $index => $node) { - $currentWeight += $node->weight; - - if ($randomWeight <= $currentWeight) { - return $collection->getNode($index); - } - } - - throw new \RuntimeException('You never will catch this exception.'); - } - - private function calculateSumWeight(CollectionInterface $collection): int - { - $sumWeight = 0; - - /** @var Node $node */ - foreach ($collection as $node) { - $sumWeight += $node->weight; - } - - return $sumWeight; - } -} diff --git a/src/Strategy/WeightedRoundRobinStrategy.php b/src/Strategy/WeightedRoundRobinStrategy.php deleted file mode 100644 index 60ebfb7..0000000 --- a/src/Strategy/WeightedRoundRobinStrategy.php +++ /dev/null @@ -1,85 +0,0 @@ -isEmpty()) { - throw new EmptyCollectionException(); - } - - if (!$collection->isWeighted()) { - throw new UnweightedCollectionException(); - } - - if (0 === $this->gcd) { - $this->gcd = $this->calculateGcd($collection); - } - - if (0 === $this->maxWeight) { - $this->maxWeight = $this->calculateMaxWeight($collection); - } - - while (true) { - $index = $this->counter->next($context['counter_name'] ?? self::class) % \count($collection); - - if (0 === $index) { - $this->currentWeight -= $this->gcd; - - if (0 >= $this->currentWeight) { - $this->currentWeight = $this->maxWeight; - } - } - - $node = $collection->getNode($index); - - if ($node->weight >= $this->currentWeight) { - return $node; - } - } - } - - private function calculateGcd(CollectionInterface $collection): int - { - $gcd = 0; - - /** @var Node $node */ - foreach ($collection as $node) { - $gcd = GcdCalculator::calculate($gcd, $node->weight); - } - - return $gcd; - } - - private function calculateMaxWeight(CollectionInterface $collection): int - { - $maxWeight = 0; - - /** @var Node $node */ - foreach ($collection as $node) { - if ($node->weight >= $maxWeight) { - $maxWeight = $node->weight; - } - } - - return $maxWeight; - } -} diff --git a/src/Throttler.php b/src/Throttler.php deleted file mode 100644 index c3430c3..0000000 --- a/src/Throttler.php +++ /dev/null @@ -1,22 +0,0 @@ -strategy->getNode($collection, $context); - } -} diff --git a/src/ThrottlerInterface.php b/src/ThrottlerInterface.php index 4e81068..4effacc 100644 --- a/src/ThrottlerInterface.php +++ b/src/ThrottlerInterface.php @@ -5,9 +5,12 @@ namespace Orangesoft\Throttler; use Orangesoft\Throttler\Collection\CollectionInterface; -use Orangesoft\Throttler\Collection\Node; +use Orangesoft\Throttler\Collection\NodeInterface; interface ThrottlerInterface { - public function pick(CollectionInterface $collection, array $context = []): Node; + /** + * @param array $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface; } diff --git a/src/WeightedRandomThrottler.php b/src/WeightedRandomThrottler.php new file mode 100644 index 0000000..50ffdf7 --- /dev/null +++ b/src/WeightedRandomThrottler.php @@ -0,0 +1,39 @@ + $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + $currentWeight = 0; + $sumWeight = array_sum(array_map(static fn (NodeInterface $node): int => $node->getWeight(), $collection->toArray())); + $randomWeight = mt_rand(1, $sumWeight); + + foreach ($collection as $node) { + if (0 === $node->getWeight()) { + throw new \RuntimeException('All nodes in the collection must be weighted.'); // @codeCoverageIgnore + } + + $currentWeight += $node->getWeight(); + + if ($randomWeight <= $currentWeight) { + return $node; + } + } + + throw new \RuntimeException('You never will catch this exception.'); // @codeCoverageIgnore + } +} diff --git a/src/WeightedRoundRobinThrottler.php b/src/WeightedRoundRobinThrottler.php new file mode 100644 index 0000000..80a5465 --- /dev/null +++ b/src/WeightedRoundRobinThrottler.php @@ -0,0 +1,81 @@ + + */ + private array $gcdWeight = []; + /** + * @var array + */ + private array $maxWeight = []; + /** + * @var array + */ + private array $currentWeight = []; + + public function __construct( + private CounterInterface $counter, + ) { + } + + /** + * @param array $context + */ + public function pick(CollectionInterface $collection, array $context = []): NodeInterface + { + if ($collection->isEmpty()) { + throw new \RuntimeException('Collection of nodes mustn\'t be empty.'); // @codeCoverageIgnore + } + + if (isset($context['counter']) && !\is_string($context['counter'])) { + throw new \RuntimeException(sprintf('The parameter "counter" must be as a string, %s given.', get_debug_type($context['counter']))); // @codeCoverageIgnore + } + + $counter = $context['counter'] ?? spl_object_hash($collection); + + if (!isset($this->gcdWeight[$counter]) || !isset($this->maxWeight[$counter]) || !isset($this->currentWeight[$counter])) { + $this->gcdWeight[$counter] = 0; + $this->maxWeight[$counter] = 0; + $this->currentWeight[$counter] = 0; + } + + if (0 === $this->gcdWeight[$counter] || 0 === $this->maxWeight[$counter] || 0 === $this->currentWeight[$counter]) { + foreach ($collection as $node) { + if (0 === $node->getWeight()) { + throw new \RuntimeException('All nodes in the collection must be weighted.'); // @codeCoverageIgnore + } + + $this->gcdWeight[$counter] = gcd($this->gcdWeight[$counter], $node->getWeight()); + $this->maxWeight[$counter] = max($this->maxWeight[$counter], $node->getWeight()); + } + } + + while (true) { + $key = $this->counter->next($counter) % \count($collection); + + if (0 == $key) { + $this->currentWeight[$counter] -= $this->gcdWeight[$counter]; + + if (0 >= $this->currentWeight[$counter]) { + $this->currentWeight[$counter] = $this->maxWeight[$counter]; + } + } + + $node = $collection->get($key); + + if ($node->getWeight() >= $this->currentWeight[$counter]) { + return $node; + } + } + } +} diff --git a/tests/Cluster/ClusterTest.php b/tests/Cluster/ClusterTest.php new file mode 100644 index 0000000..707e4e3 --- /dev/null +++ b/tests/Cluster/ClusterTest.php @@ -0,0 +1,49 @@ +balance($pool); + } + + $this->assertSame($expectedNodes, array_map(static fn (NodeInterface $actualNode): string => $actualNode->getName(), $actualNodes)); + } +} diff --git a/tests/Collection/ClusterTest.php b/tests/Collection/ClusterTest.php deleted file mode 100644 index 4c65ecf..0000000 --- a/tests/Collection/ClusterTest.php +++ /dev/null @@ -1,35 +0,0 @@ -assertSame($node, $cluster->balance($throttler)); - } -} diff --git a/tests/Collection/CollectionTest.php b/tests/Collection/CollectionTest.php deleted file mode 100644 index 0b26efb..0000000 --- a/tests/Collection/CollectionTest.php +++ /dev/null @@ -1,171 +0,0 @@ -assertCount(4, $collection); - } - - public function testGetNode(): void - { - $nodes = [ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]; - - $collection = new Collection($nodes); - - $this->assertSame($nodes[2], $collection->getNode(2)); - } - - public function testHasNode(): void - { - $node = new Node('node1'); - - $collection = new Collection([ - $node, - ]); - - $this->assertTrue($collection->hasNode($node)); - } - - public function testRemoveNode(): void - { - $node = new Node('node1'); - - $collection = new Collection([ - $node, - ]); - - $collection->removeNode($node); - - $this->assertFalse($collection->hasNode($node)); - } - - public function testReindex(): void - { - $nodes = [ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]; - - $collection = new Collection($nodes); - - $collection->removeNode($nodes[0]); - - $this->assertSame($nodes[1], $collection->getNode(0)); - } - - public function testPurge(): void - { - $collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]); - - $collection->purge(); - - $this->assertCount(0, $collection); - } - - public function testWeightedCollection(): void - { - $collection = new Collection([ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), - ]); - - $this->assertTrue($collection->isWeighted()); - } - - public function testUnweightedCollection(): void - { - $collection = new Collection([ - new Node('node1', 5), - new Node('node2', 0), - new Node('node3', 0), - ]); - - $this->assertFalse($collection->isWeighted()); - } - - public function testEmpty(): void - { - $collection = new Collection(); - - $this->assertTrue($collection->isEmpty()); - } - - public function testNotEmpty(): void - { - $collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]); - - $this->assertFalse($collection->isEmpty()); - } - - public function testToArray(): void - { - $collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]); - - $this->assertIsArray($collection->toArray()); - $this->assertCount(3, $collection->toArray()); - } - - public function testCountable(): void - { - $collection = new Collection([ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]); - - $this->assertCount(3, $collection); - } - - public function testIterable(): void - { - $expectedNodes = [ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]; - - $collection = new Collection($expectedNodes); - - $this->assertIsIterable($collection); - - $actualNodes = iterator_to_array($collection); - - $this->assertSame($expectedNodes[0], $actualNodes[0]); - $this->assertSame($expectedNodes[1], $actualNodes[1]); - $this->assertSame($expectedNodes[2], $actualNodes[2]); - } -} diff --git a/tests/Collection/InMemoryCollectionTest.php b/tests/Collection/InMemoryCollectionTest.php new file mode 100644 index 0000000..6dfe711 --- /dev/null +++ b/tests/Collection/InMemoryCollectionTest.php @@ -0,0 +1,223 @@ +assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $collection->toArray())); + } + + public function testCreateCollectionWithNotUniqueNodes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All nodes must be unique, "192.168.0.1" given as duplicate.'); + + new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.1'), + ]); + } + + public function testAddNodeToExistedCollection(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + ]); + + $other = $collection->add(new Node('192.168.0.3')); + + $expectedResult = [ + '192.168.0.1', + '192.168.0.2', + '192.168.0.3', + ]; + + $this->assertNotSame($collection, $other); + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $other->toArray())); + } + + public function testAddTheSameNodeToExistedCollection(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + ]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The node "192.168.0.2" has been already added.'); + + $collection->add(new Node('192.168.0.2')); + } + + public function testGetNodeByKey(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + ]); + + $expectedResult = [ + '192.168.0.1', + '192.168.0.2', + '192.168.0.3', + ]; + $actualResult = []; + + for ($i = 0; $i < 3; ++$i) { + $actualResult[] = $collection->get($i); + } + + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $actualResult)); + } + + public function testGetOutOfRangeNodeByKey(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + ]); + + $this->expectException(\OutOfRangeException::class); + $this->expectExceptionMessage('Can\'t get node at key "3".'); + + $collection->get(3); + } + + public function testHasNodeInCollection(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + ]); + + $this->assertTrue($collection->has(new Node('192.168.0.1'))); + } + + public function testHasNodeInEmptyCollection(): void + { + $collection = new InMemoryCollection(); + + $this->assertFalse($collection->has(new Node('192.168.0.1'))); + } + + public function testRemoveNode(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + ]); + + $other = $collection->remove(new Node('192.168.0.3')); + + $expectedResult = [ + '192.168.0.1', + '192.168.0.2', + ]; + + $this->assertNotSame($collection, $other); + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $other->toArray())); + } + + public function testRemoveNotAddedNode(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + ]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The node "192.168.0.3" hasn\'t been already added.'); + + $collection->remove(new Node('192.168.0.3')); + } + + public function testSortCollectionByWeightAscending(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1', 3), + new Node('192.168.0.2', 2), + new Node('192.168.0.3', 1), + ]); + + $other = $collection->sort(new Asc()); + + $expectedResult = [ + '192.168.0.3', + '192.168.0.2', + '192.168.0.1', + ]; + + $this->assertNotSame($collection, $other); + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $other->toArray())); + } + + public function testSortCollectionByWeightDescending(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1', 1), + new Node('192.168.0.2', 2), + new Node('192.168.0.3', 3), + ]); + + $other = $collection->sort(new Desc()); + + $expectedResult = [ + '192.168.0.3', + '192.168.0.2', + '192.168.0.1', + ]; + + $this->assertNotSame($collection, $other); + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $other->toArray())); + } + + public function testCollectionTraversable(): void + { + $collection = new InMemoryCollection([ + new Node('192.168.0.1'), + new Node('192.168.0.2'), + new Node('192.168.0.3'), + ]); + + $expectedResult = [ + '192.168.0.1', + '192.168.0.2', + '192.168.0.3', + ]; + $actualNodes = []; + + foreach ($collection as $key => $node) { + $actualNodes[$key] = $node; + } + + $this->assertSame($expectedResult, array_map(static fn (NodeInterface $node): string => $node->getName(), $actualNodes)); + } +} diff --git a/tests/Collection/NodeTest.php b/tests/Collection/NodeTest.php new file mode 100644 index 0000000..a1fdea4 --- /dev/null +++ b/tests/Collection/NodeTest.php @@ -0,0 +1,26 @@ + 'http://127.0.0.1/', + ], + ); + + $this->assertSame('192.168.0.1', $node->getName()); + $this->assertSame(5, $node->getWeight()); + $this->assertSame(['callback_url' => 'http://127.0.0.1/'], $node->getPayload()); + } +} diff --git a/tests/Collection/SorterTest.php b/tests/Collection/SorterTest.php deleted file mode 100644 index b5a28a8..0000000 --- a/tests/Collection/SorterTest.php +++ /dev/null @@ -1,53 +0,0 @@ -sort($collection, new Asc()); - - $this->assertSame('node1', $collection->getNode(0)->name); - $this->assertSame('node2', $collection->getNode(1)->name); - $this->assertSame('node3', $collection->getNode(2)->name); - $this->assertSame('node4', $collection->getNode(3)->name); - } - - public function testSortDesc(): void - { - $collection = new Collection([ - new Node('node2', 8), - new Node('node3', 16), - new Node('node1', 4), - new Node('node4', 32), - ]); - - $sorter = new Sorter(); - - $sorter->sort($collection, new Desc()); - - $this->assertSame('node4', $collection->getNode(0)->name); - $this->assertSame('node3', $collection->getNode(1)->name); - $this->assertSame('node2', $collection->getNode(2)->name); - $this->assertSame('node1', $collection->getNode(3)->name); - } -} diff --git a/tests/Counter/InMemoryCounterTest.php b/tests/Counter/InMemoryCounterTest.php new file mode 100644 index 0000000..21f5753 --- /dev/null +++ b/tests/Counter/InMemoryCounterTest.php @@ -0,0 +1,64 @@ +next(); + } + + $this->assertSame($expectedResult, $actualResult); + } + + public function testInMemoryCounterWithStartNumber(): void + { + $counter = new InMemoryCounter( + start: 10, + ); + + $expectedResult = range(10, 15); + $actualResult = []; + + for ($i = 0; $i < 6; ++$i) { + $actualResult[] = $counter->next(); + } + + $this->assertSame($expectedResult, $actualResult); + } + + public function testInMemoryCounterWithDifferentNames(): void + { + $counter = new InMemoryCounter(); + + $expectedResult = [ + 0, + 0, + 1, + 1, + 2, + 2, + ]; + $actualResult = []; + + for ($i = 0; $i < 6; ++$i) { + $actualResult[] = $counter->next( + name: 0 === $i % 2 ? 'a' : 'b', + ); + } + + $this->assertSame($expectedResult, $actualResult); + } +} diff --git a/tests/FrequencyRandomThrottlerTest.php b/tests/FrequencyRandomThrottlerTest.php new file mode 100644 index 0000000..466b61c --- /dev/null +++ b/tests/FrequencyRandomThrottlerTest.php @@ -0,0 +1,43 @@ +pick($collection); + + if (!isset($actualNodes[$node->getName()])) { + $actualNodes[$node->getName()] = 0; + } + + ++$actualNodes[$node->getName()]; + } + + $this->assertCount(3, $actualNodes); + $this->assertEquals(1_000, array_sum($actualNodes)); + $this->assertGreaterThan($actualNodes['192.168.0.2'], $actualNodes['192.168.0.1']); + $this->assertGreaterThan($actualNodes['192.168.0.3'], $actualNodes['192.168.0.1']); + } +} diff --git a/tests/MultipleThrottlerTest.php b/tests/MultipleThrottlerTest.php new file mode 100644 index 0000000..eb17ee4 --- /dev/null +++ b/tests/MultipleThrottlerTest.php @@ -0,0 +1,48 @@ +pick($collection, [ + 'throttler' => RoundRobinThrottler::class, + ]); + } + + $this->assertSame($expectedNodes, array_map(static fn (NodeInterface $actualNode): string => $actualNode->getName(), $actualNodes)); + } +} diff --git a/tests/RandomThrottlerTest.php b/tests/RandomThrottlerTest.php new file mode 100644 index 0000000..524d662 --- /dev/null +++ b/tests/RandomThrottlerTest.php @@ -0,0 +1,38 @@ +pick($collection); + + if (!isset($actualNodes[$node->getName()])) { + $actualNodes[$node->getName()] = 0; + } + + ++$actualNodes[$node->getName()]; + } + + $this->assertCount(3, $actualNodes); + $this->assertEquals(1_000, array_sum($actualNodes)); + } +} diff --git a/tests/RoundRobinThrottlerTest.php b/tests/RoundRobinThrottlerTest.php new file mode 100644 index 0000000..5db77bb --- /dev/null +++ b/tests/RoundRobinThrottlerTest.php @@ -0,0 +1,41 @@ +pick($collection); + } + + $this->assertSame($expectedNodes, array_map(static fn (NodeInterface $actualNode): string => $actualNode->getName(), $actualNodes)); + } +} diff --git a/tests/SmoothWeightedRoundRobinThrottlerTest.php b/tests/SmoothWeightedRoundRobinThrottlerTest.php new file mode 100644 index 0000000..10454e1 --- /dev/null +++ b/tests/SmoothWeightedRoundRobinThrottlerTest.php @@ -0,0 +1,41 @@ +pick($collection); + } + + $this->assertSame($expectedNodes, array_map(static fn (NodeInterface $actualNode): string => $actualNode->getName(), $actualNodes)); + } +} diff --git a/tests/Strategy/ClusterDetermineStrategyTest.php b/tests/Strategy/ClusterDetermineStrategyTest.php deleted file mode 100644 index f300eb6..0000000 --- a/tests/Strategy/ClusterDetermineStrategyTest.php +++ /dev/null @@ -1,37 +0,0 @@ -assertSame($expectedNodes[0], $strategy->getNode($collection, ['cluster_name' => 'cluster1'])); - $this->assertSame($expectedNodes[1], $strategy->getNode($collection, ['cluster_name' => 'cluster1'])); - $this->assertSame($expectedNodes[2], $strategy->getNode($collection, ['cluster_name' => 'cluster1'])); - } -} diff --git a/tests/Strategy/FrequencyRandomStrategyTest.php b/tests/Strategy/FrequencyRandomStrategyTest.php deleted file mode 100644 index c09c760..0000000 --- a/tests/Strategy/FrequencyRandomStrategyTest.php +++ /dev/null @@ -1,48 +0,0 @@ - new Node('node1'), - 'node2' => new Node('node2'), - 'node3' => new Node('node3'), - ]; - - $collection = new Collection($nodes); - - $strategy = new FrequencyRandomStrategy(frequency: 0.8, depth: 0.2); - - $indexes = []; - - for ($i = 0; $i < 1000; ++$i) { - $node = $strategy->getNode($collection); - - if (!isset($indexes[$node->name])) { - $indexes[$node->name] = 0; - } - - ++$indexes[$node->name]; - } - - $this->assertCount(3, $indexes); - - foreach ($indexes as $count) { - $this->assertGreaterThan(0, $count); - } - - $this->assertEquals(1000, array_sum($indexes)); - $this->assertGreaterThan($indexes['node2'], $indexes['node1']); - $this->assertGreaterThan($indexes['node3'], $indexes['node1']); - } -} diff --git a/tests/Strategy/GcdCalculatorTest.php b/tests/Strategy/GcdCalculatorTest.php deleted file mode 100644 index 0ee8db7..0000000 --- a/tests/Strategy/GcdCalculatorTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertEquals(3, $gcd); - } -} diff --git a/tests/Strategy/InMemoryCounterTest.php b/tests/Strategy/InMemoryCounterTest.php deleted file mode 100644 index 093a2e2..0000000 --- a/tests/Strategy/InMemoryCounterTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertEquals(5, $counter->next('a')); - $this->assertEquals(5, $counter->next('b')); - $this->assertEquals(6, $counter->next('a')); - $this->assertEquals(6, $counter->next('b')); - } -} diff --git a/tests/Strategy/MultipleDynamicStrategyTest.php b/tests/Strategy/MultipleDynamicStrategyTest.php deleted file mode 100644 index 33e1c58..0000000 --- a/tests/Strategy/MultipleDynamicStrategyTest.php +++ /dev/null @@ -1,36 +0,0 @@ -assertSame($expectedNodes[0], $strategy->getNode($collection, ['strategy_name' => RoundRobinStrategy::class])); - $this->assertSame($expectedNodes[1], $strategy->getNode($collection, ['strategy_name' => RoundRobinStrategy::class])); - $this->assertSame($expectedNodes[2], $strategy->getNode($collection, ['strategy_name' => RoundRobinStrategy::class])); - } -} diff --git a/tests/Strategy/RandomStrategyTest.php b/tests/Strategy/RandomStrategyTest.php deleted file mode 100644 index c306bed..0000000 --- a/tests/Strategy/RandomStrategyTest.php +++ /dev/null @@ -1,46 +0,0 @@ - new Node('node1'), - 'node2' => new Node('node2'), - 'node3' => new Node('node3'), - ]; - - $collection = new Collection($nodes); - - $strategy = new RandomStrategy(); - - $indexes = []; - - for ($i = 0; $i < 1000; ++$i) { - $node = $strategy->getNode($collection); - - if (!isset($indexes[$node->name])) { - $indexes[$node->name] = 0; - } - - ++$indexes[$node->name]; - } - - $this->assertCount(3, $indexes); - - foreach ($indexes as $count) { - $this->assertGreaterThan(0, $count); - } - - $this->assertEquals(1000, array_sum($indexes)); - } -} diff --git a/tests/Strategy/RoundRobinStrategyTest.php b/tests/Strategy/RoundRobinStrategyTest.php deleted file mode 100644 index a54659c..0000000 --- a/tests/Strategy/RoundRobinStrategyTest.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ - private array $expectedNodes; - - protected function setUp(): void - { - $this->expectedNodes = [ - new Node('node1'), - new Node('node2'), - new Node('node3'), - ]; - } - - public function testRoundRobin(): InMemoryCounter - { - $counter = new InMemoryCounter(start: 0); - $strategy = new RoundRobinStrategy($counter); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[1], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[2], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - - return $counter; - } - - /** - * @depends testRoundRobin - */ - public function testRoundRobinRestart(InMemoryCounter $counter): void - { - $strategy = new RoundRobinStrategy($counter); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[1], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[2], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - } -} diff --git a/tests/Strategy/SmoothWeightedRoundRobinStrategyTest.php b/tests/Strategy/SmoothWeightedRoundRobinStrategyTest.php deleted file mode 100644 index 744e09b..0000000 --- a/tests/Strategy/SmoothWeightedRoundRobinStrategyTest.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ - private array $expectedNodes; - - protected function setUp(): void - { - $this->expectedNodes = [ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), - ]; - } - - public function testSmoothWeightedRoundRobin(): string - { - $strategy = new SmoothWeightedRoundRobinStrategy(); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[1], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - - return serialize($strategy); - } - - /** - * @depends testSmoothWeightedRoundRobin - */ - public function testRestartSmoothWeightedRoundRobin(string $serializedStrategy): void - { - /** @var SmoothWeightedRoundRobinStrategy $strategy */ - $strategy = unserialize($serializedStrategy); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[2], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - } -} diff --git a/tests/Strategy/WeightedRandomStrategyTest.php b/tests/Strategy/WeightedRandomStrategyTest.php deleted file mode 100644 index b4b67fc..0000000 --- a/tests/Strategy/WeightedRandomStrategyTest.php +++ /dev/null @@ -1,48 +0,0 @@ - new Node('node1', 10), - 'node2' => new Node('node2', 5), - 'node3' => new Node('node3', 1), - ]; - - $collection = new Collection($nodes); - - $strategy = new WeightedRandomStrategy(); - - $indexes = []; - - for ($i = 0; $i < 1000; ++$i) { - $node = $strategy->getNode($collection); - - if (!isset($indexes[$node->name])) { - $indexes[$node->name] = 0; - } - - ++$indexes[$node->name]; - } - - $this->assertCount(3, $indexes); - - foreach ($indexes as $count) { - $this->assertGreaterThan(0, $count); - } - - $this->assertEquals(1000, array_sum($indexes)); - $this->assertGreaterThan($indexes['node2'], $indexes['node1']); - $this->assertGreaterThan($indexes['node3'], $indexes['node2']); - } -} diff --git a/tests/Strategy/WeightedRoundRobinStrategyTest.php b/tests/Strategy/WeightedRoundRobinStrategyTest.php deleted file mode 100644 index 185cde6..0000000 --- a/tests/Strategy/WeightedRoundRobinStrategyTest.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - private array $expectedNodes; - - protected function setUp(): void - { - $this->expectedNodes = [ - new Node('node1', 5), - new Node('node2', 1), - new Node('node3', 1), - ]; - } - - public function testWeightedRoundRobin(): InMemoryCounter - { - $counter = new InMemoryCounter(start: 0); - $strategy = new WeightedRoundRobinStrategy($counter); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - - return $counter; - } - - /** - * @depends testWeightedRoundRobin - */ - public function testRestartWeightedRoundRobin(InMemoryCounter $counter): void - { - $strategy = new WeightedRoundRobinStrategy($counter); - $collection = new Collection($this->expectedNodes); - - $this->assertSame($this->expectedNodes[1], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[2], $strategy->getNode($collection)); - $this->assertSame($this->expectedNodes[0], $strategy->getNode($collection)); - } -} diff --git a/tests/ThrottlerTest.php b/tests/ThrottlerTest.php deleted file mode 100644 index 5161751..0000000 --- a/tests/ThrottlerTest.php +++ /dev/null @@ -1,29 +0,0 @@ -assertSame($node, $throttler->pick($collection)); - } -} diff --git a/tests/WeightedRandomThrottlerTest.php b/tests/WeightedRandomThrottlerTest.php new file mode 100644 index 0000000..114fd4a --- /dev/null +++ b/tests/WeightedRandomThrottlerTest.php @@ -0,0 +1,40 @@ +pick($collection); + + if (!isset($actualNodes[$node->getName()])) { + $actualNodes[$node->getName()] = 0; + } + + ++$actualNodes[$node->getName()]; + } + + $this->assertCount(3, $actualNodes); + $this->assertEquals(1_000, array_sum($actualNodes)); + $this->assertGreaterThan($actualNodes['192.168.0.2'], $actualNodes['192.168.0.1']); + $this->assertGreaterThan($actualNodes['192.168.0.3'], $actualNodes['192.168.0.2']); + } +} diff --git a/tests/WeightedRoundRobinThrottlerTest.php b/tests/WeightedRoundRobinThrottlerTest.php new file mode 100644 index 0000000..90da41e --- /dev/null +++ b/tests/WeightedRoundRobinThrottlerTest.php @@ -0,0 +1,42 @@ +pick($collection); + } + + $this->assertSame($expectedNodes, array_map(static fn (NodeInterface $actualNode): string => $actualNode->getName(), $actualNodes)); + } +}