Skip to content

Commit 8d47a2a

Browse files
committed
feat: initial commit
1 parent c665278 commit 8d47a2a

15 files changed

+693
-5
lines changed

.github/workflows/build.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: PHP Multi-probe Consistent Hashing build
2+
on:
3+
push:
4+
branches: [ "main" ]
5+
pull_request:
6+
branches: [ "main" ]
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
strategy:
11+
matrix:
12+
php:
13+
- "8.2"
14+
- "8.3"
15+
dependencies:
16+
- "lowest"
17+
- "highest"
18+
include:
19+
- php-version: "8.3"
20+
composer-options: "--ignore-platform-reqs"
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
- name: Install PHP
25+
uses: "shivammathur/setup-php@v2"
26+
with:
27+
php-version: "${{ matrix.php }}"
28+
- name: Composer install
29+
uses: "ramsey/composer-install@v3"
30+
with:
31+
dependency-versions: "${{ matrix.dependencies }}"
32+
composer-options: "${{ matrix.composer-options }}"
33+
34+
- name: Run tests
35+
run: composer test
36+
37+
- name: PHPStan static analysis
38+
run: composer phpstan
39+
40+
- name: Code Coverage Report
41+
uses: irongut/[email protected]
42+
with:
43+
filename: cobertura.xml
44+
badge: true
45+
fail_below_min: true
46+
format: markdown
47+
hide_branch_rate: false
48+
hide_complexity: true
49+
indicators: true
50+
output: both
51+
thresholds: '60 80'
52+
53+
- name: Add Coverage PR Comment
54+
uses: marocchino/sticky-pull-request-comment@v2
55+
if: github.event_name == 'pull_request'
56+
with:
57+
recreate: true
58+
path: code-coverage-results.md

.gitignore

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
composer.phar
1+
composer.lock
22
/vendor/
3-
4-
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
5-
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
6-
# composer.lock
3+
/build/
4+
.idea
5+
.phpunit.cache
6+
clover.xml

.idea/.gitignore

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.run/PHPUnit.run.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<component name="ProjectRunConfigurationManager">
2+
<configuration default="false" name="PHPUnit" type="PHPUnitRunConfigurationType" factoryName="PHPUnit">
3+
<CommandLine>
4+
<PhpTestInterpreterSettings>
5+
<option name="interpreterName" value="PHP 8.3" />
6+
</PhpTestInterpreterSettings>
7+
</CommandLine>
8+
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" coverage_engine="PCov" scope="XML" use_alternative_configuration_file="true" />
9+
<method v="2" />
10+
</configuration>
11+
</component>

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,40 @@
11
# php-consistent-hashing
22
Multi-probe consistent hashing, using php
3+
4+
The idea behind this package is that you can distribute keys to nodes in a consistent/sticky way, while also allowing for custom weights for each node.
5+
This allows you to route 'traffic' to nodes depending on an equal or custom distibution.
6+
7+
The algorithm should distribute more evenly than for example libketama, which can have a deviation between distributions of equal weight as big as about 10%.
8+
For more information on this algorithm and consistent hashing in general, see:
9+
-
10+
- https://en.wikipedia.org/wiki/Consistent_hashing
11+
12+
Use cases:
13+
- Load balancing.
14+
- Distributing users to groups, for example for use in A/B testing.
15+
16+
Example usage:
17+
```php
18+
$hasher = new \Jspeedz\PhpConsistentHashing\MultiProbeConsistentHash();
19+
// Choose which hash functions to use
20+
$hasher->setHashFunctions((new \Jspeedz\PhpConsistentHashing\HashFunctions\Standard)());
21+
```
22+
23+
Usage with equal weights:
24+
```php
25+
$hasher->addNode('node1');
26+
$hasher->addNode('node2');
27+
$hasher->addNode('node2');
28+
29+
$node = $hasher->hash('some_key1');
30+
```
31+
32+
Usage with custom weights (I like to use percentages):
33+
```php
34+
$hasher->addNode('node1', 25);
35+
$hasher->addNode('node2', 33.3);
36+
$hasher->addNode('node2', 41.7);
37+
38+
$node = $hasher->hash('some_key1');
39+
```
40+
Note: if you add a node without specifying a weight, the weight will be set to 1.

composer.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "jspeedz/php-consistent-hashing",
3+
"description": "Multi-probe consistent hashing implementation for PHP",
4+
"type": "library",
5+
"require-dev": {
6+
"phpunit/phpunit": "^11.2",
7+
"phpstan/phpstan": "^1.11"
8+
},
9+
"license": "GPL-3.0-only",
10+
"authors": [
11+
{
12+
"name": "jspeedz"
13+
}
14+
],
15+
"autoload": {
16+
"psr-4": {
17+
"Jspeedz\\PhpConsistentHashing\\": "src/"
18+
}
19+
},
20+
"scripts": {
21+
"generate-test-data": "@php tests/data/generatedata.php",
22+
"test": "@php vendor/bin/phpunit",
23+
"phpstan": "@php vendor/bin/phpstan analyse",
24+
"phpstanpro": "@php vendor/bin/phpstan --pro"
25+
},
26+
"scripts-descriptions": {
27+
"generate-test-data": "Re-generate test data for tests",
28+
"test": "Run all tests",
29+
"phpstan": "Runs PHPStan",
30+
"phpstanpro": "Runs PHPStan in PRO mode!"
31+
},
32+
"require": {
33+
"php": ">=8.2"
34+
},
35+
"config": {
36+
"process-timeout": 0
37+
}
38+
}

phpstan.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
parameters:
2+
level: 9
3+
paths:
4+
- src
5+
- tests

phpunit.xml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
cacheDirectory=".phpunit.cache"
6+
executionOrder="depends,defects"
7+
requireCoverageMetadata="true"
8+
beStrictAboutCoverageMetadata="true"
9+
beStrictAboutOutputDuringTests="true"
10+
failOnRisky="true"
11+
failOnWarning="true">
12+
<testsuites>
13+
<testsuite name="default">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
18+
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
19+
<include>
20+
<directory>src</directory>
21+
</include>
22+
</source>
23+
24+
<coverage includeUncoveredFiles="true"
25+
pathCoverage="false"
26+
ignoreDeprecatedCodeUnits="true"
27+
disableCodeCoverageIgnore="true">
28+
<report>
29+
<cobertura outputFile="cobertura.xml"/>
30+
<clover outputFile="clover.xml"/>
31+
</report>
32+
</coverage>
33+
</phpunit>

src/HashFunctions/Standard.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Jspeedz\PhpConsistentHashing\HashFunctions;
4+
5+
class Standard {
6+
/**
7+
* @return callable[]
8+
*/
9+
public function __invoke(): array {
10+
return [
11+
function(string $key): int {
12+
return crc32($key);
13+
},
14+
function(string $key): float|int {
15+
return hexdec(substr(hash('md5', $key), 0, 8));
16+
},
17+
function(string $key): float|int {
18+
return hexdec(substr(hash('sha256', $key), 0, 8));
19+
},
20+
];
21+
}
22+
}

src/MultiProbeConsistentHash.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Jspeedz\PhpConsistentHashing;
4+
5+
class MultiProbeConsistentHash {
6+
/**
7+
* @var array<string, float> $nodes
8+
*/
9+
private array $nodes = [];
10+
private float $totalWeight = 0;
11+
private int $probeCount;
12+
/**
13+
* @var callable[] $hashFunctions
14+
*/
15+
private array $hashFunctions;
16+
17+
/**
18+
* @param callable[] $hashFunctions
19+
*/
20+
public function setHashFunctions(array $hashFunctions): void {
21+
$this->probeCount = count($hashFunctions);
22+
$this->hashFunctions = array_values($hashFunctions);
23+
}
24+
25+
public function addNode(?string $node, float $weight = 1): void {
26+
$this->nodes[$node] = $weight;
27+
$this->totalWeight += $weight;
28+
}
29+
30+
public function removeNode(string $node): void {
31+
if(isset($this->nodes[$node])) {
32+
$this->totalWeight -= $this->nodes[$node];
33+
unset($this->nodes[$node]);
34+
}
35+
}
36+
37+
public function getNode(string $key): ?string {
38+
if(empty($this->nodes)) {
39+
return null;
40+
}
41+
42+
$minHash = PHP_INT_MAX;
43+
$targetNode = null;
44+
45+
foreach($this->nodes as $node => $weight) {
46+
for($i = 0; $i < $this->probeCount; $i++) {
47+
$hash = $this->hashFunctions[$i]($key . $node);
48+
// Adjust hash by weight
49+
$weightedHash = $hash / $weight;
50+
if($weightedHash < $minHash) {
51+
$minHash = $weightedHash;
52+
$targetNode = $node;
53+
}
54+
}
55+
}
56+
57+
return $targetNode;
58+
}
59+
}

tests/HashFunctions/StandardTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Jspeedz\PhpConsistentHashing\Tests\HashFunctions;
4+
5+
use Jspeedz\PhpConsistentHashing\HashFunctions\Standard;
6+
use PHPUnit\Framework\Attributes\CoversClass;
7+
use PHPUnit\Framework\TestCase;
8+
9+
#[CoversClass(Standard::class)]
10+
class StandardTest extends TestCase {
11+
public function testStandardCallbacks(): void {
12+
$callbacks = (new Standard())();
13+
14+
$this->assertCount(3, $callbacks);
15+
16+
$this->assertIsCallable($callbacks[0]);
17+
$this->assertIsCallable($callbacks[1]);
18+
$this->assertIsCallable($callbacks[2]);
19+
20+
$this->assertSame(
21+
crc32('test'),
22+
$callbacks[0]('test'),
23+
);
24+
$this->assertSame(
25+
hexdec(substr(hash('md5', 'test'), 0, 8)),
26+
$callbacks[1]('test'),
27+
);
28+
$this->assertSame(
29+
hexdec(substr(hash('sha256', 'test'), 0, 8)),
30+
$callbacks[2]('test'),
31+
);
32+
}
33+
}

0 commit comments

Comments
 (0)