Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jspeedz committed Jul 6, 2024
1 parent c665278 commit 8d47a2a
Show file tree
Hide file tree
Showing 15 changed files with 693 additions and 5 deletions.
58 changes: 58 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: PHP Multi-probe Consistent Hashing build
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php:
- "8.2"
- "8.3"
dependencies:
- "lowest"
- "highest"
include:
- php-version: "8.3"
composer-options: "--ignore-platform-reqs"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install PHP
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php }}"
- name: Composer install
uses: "ramsey/composer-install@v3"
with:
dependency-versions: "${{ matrix.dependencies }}"
composer-options: "${{ matrix.composer-options }}"

- name: Run tests
run: composer test

- name: PHPStan static analysis
run: composer phpstan

- name: Code Coverage Report
uses: irongut/[email protected]
with:
filename: cobertura.xml
badge: true
fail_below_min: true
format: markdown
hide_branch_rate: false
hide_complexity: true
indicators: true
output: both
thresholds: '60 80'

- name: Add Coverage PR Comment
uses: marocchino/sticky-pull-request-comment@v2
if: github.event_name == 'pull_request'
with:
recreate: true
path: code-coverage-results.md
10 changes: 5 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
composer.phar
composer.lock
/vendor/

# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
/build/
.idea
.phpunit.cache
clover.xml
8 changes: 8 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions .run/PHPUnit.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="PHPUnit" type="PHPUnitRunConfigurationType" factoryName="PHPUnit">
<CommandLine>
<PhpTestInterpreterSettings>
<option name="interpreterName" value="PHP 8.3" />
</PhpTestInterpreterSettings>
</CommandLine>
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" coverage_engine="PCov" scope="XML" use_alternative_configuration_file="true" />
<method v="2" />
</configuration>
</component>
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,40 @@
# php-consistent-hashing
Multi-probe consistent hashing, using php

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.
This allows you to route 'traffic' to nodes depending on an equal or custom distibution.

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%.
For more information on this algorithm and consistent hashing in general, see:
-
- https://en.wikipedia.org/wiki/Consistent_hashing

Use cases:
- Load balancing.
- Distributing users to groups, for example for use in A/B testing.

Example usage:
```php
$hasher = new \Jspeedz\PhpConsistentHashing\MultiProbeConsistentHash();
// Choose which hash functions to use
$hasher->setHashFunctions((new \Jspeedz\PhpConsistentHashing\HashFunctions\Standard)());
```

Usage with equal weights:
```php
$hasher->addNode('node1');
$hasher->addNode('node2');
$hasher->addNode('node2');

$node = $hasher->hash('some_key1');
```

Usage with custom weights (I like to use percentages):
```php
$hasher->addNode('node1', 25);
$hasher->addNode('node2', 33.3);
$hasher->addNode('node2', 41.7);

$node = $hasher->hash('some_key1');
```
Note: if you add a node without specifying a weight, the weight will be set to 1.
38 changes: 38 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "jspeedz/php-consistent-hashing",
"description": "Multi-probe consistent hashing implementation for PHP",
"type": "library",
"require-dev": {
"phpunit/phpunit": "^11.2",
"phpstan/phpstan": "^1.11"
},
"license": "GPL-3.0-only",
"authors": [
{
"name": "jspeedz"
}
],
"autoload": {
"psr-4": {
"Jspeedz\\PhpConsistentHashing\\": "src/"
}
},
"scripts": {
"generate-test-data": "@php tests/data/generatedata.php",
"test": "@php vendor/bin/phpunit",
"phpstan": "@php vendor/bin/phpstan analyse",
"phpstanpro": "@php vendor/bin/phpstan --pro"
},
"scripts-descriptions": {
"generate-test-data": "Re-generate test data for tests",
"test": "Run all tests",
"phpstan": "Runs PHPStan",
"phpstanpro": "Runs PHPStan in PRO mode!"
},
"require": {
"php": ">=8.2"
},
"config": {
"process-timeout": 0
}
}
5 changes: 5 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 9
paths:
- src
- tests
33 changes: 33 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>

<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>

<coverage includeUncoveredFiles="true"
pathCoverage="false"
ignoreDeprecatedCodeUnits="true"
disableCodeCoverageIgnore="true">
<report>
<cobertura outputFile="cobertura.xml"/>
<clover outputFile="clover.xml"/>
</report>
</coverage>
</phpunit>
22 changes: 22 additions & 0 deletions src/HashFunctions/Standard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types=1);

namespace Jspeedz\PhpConsistentHashing\HashFunctions;

class Standard {
/**
* @return callable[]
*/
public function __invoke(): array {
return [
function(string $key): int {
return crc32($key);
},
function(string $key): float|int {
return hexdec(substr(hash('md5', $key), 0, 8));
},
function(string $key): float|int {
return hexdec(substr(hash('sha256', $key), 0, 8));
},
];
}
}
59 changes: 59 additions & 0 deletions src/MultiProbeConsistentHash.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php declare(strict_types=1);

namespace Jspeedz\PhpConsistentHashing;

class MultiProbeConsistentHash {
/**
* @var array<string, float> $nodes
*/
private array $nodes = [];
private float $totalWeight = 0;
private int $probeCount;
/**
* @var callable[] $hashFunctions
*/
private array $hashFunctions;

/**
* @param callable[] $hashFunctions
*/
public function setHashFunctions(array $hashFunctions): void {
$this->probeCount = count($hashFunctions);
$this->hashFunctions = array_values($hashFunctions);
}

public function addNode(?string $node, float $weight = 1): void {
$this->nodes[$node] = $weight;
$this->totalWeight += $weight;
}

public function removeNode(string $node): void {
if(isset($this->nodes[$node])) {
$this->totalWeight -= $this->nodes[$node];
unset($this->nodes[$node]);
}
}

public function getNode(string $key): ?string {
if(empty($this->nodes)) {
return null;
}

$minHash = PHP_INT_MAX;
$targetNode = null;

foreach($this->nodes as $node => $weight) {
for($i = 0; $i < $this->probeCount; $i++) {
$hash = $this->hashFunctions[$i]($key . $node);
// Adjust hash by weight
$weightedHash = $hash / $weight;
if($weightedHash < $minHash) {
$minHash = $weightedHash;
$targetNode = $node;
}
}
}

return $targetNode;
}
}
33 changes: 33 additions & 0 deletions tests/HashFunctions/StandardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

namespace Jspeedz\PhpConsistentHashing\Tests\HashFunctions;

use Jspeedz\PhpConsistentHashing\HashFunctions\Standard;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Standard::class)]
class StandardTest extends TestCase {
public function testStandardCallbacks(): void {
$callbacks = (new Standard())();

$this->assertCount(3, $callbacks);

$this->assertIsCallable($callbacks[0]);
$this->assertIsCallable($callbacks[1]);
$this->assertIsCallable($callbacks[2]);

$this->assertSame(
crc32('test'),
$callbacks[0]('test'),
);
$this->assertSame(
hexdec(substr(hash('md5', 'test'), 0, 8)),
$callbacks[1]('test'),
);
$this->assertSame(
hexdec(substr(hash('sha256', 'test'), 0, 8)),
$callbacks[2]('test'),
);
}
}
Loading

0 comments on commit 8d47a2a

Please sign in to comment.