Merge pull request #56120 from nextcloud/feat/snowflake-file-sequence
commit
69ec2ce26b
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OC\Snowflake;
|
||||
|
||||
use Override;
|
||||
|
||||
class APCuSequence implements ISequence {
|
||||
#[Override]
|
||||
public function isAvailable(): bool {
|
||||
return PHP_SAPI !== 'cli' && function_exists('apcu_enabled') && apcu_enabled();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function nextId(int $serverId, int $seconds, int $milliseconds): int|false {
|
||||
if ((int)apcu_cache_info(true)['creation_time'] === $seconds) {
|
||||
// APCu cache was just started
|
||||
// It means a sequence was maybe deleted
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = 'seq:' . $seconds . ':' . $milliseconds;
|
||||
$sequenceId = apcu_inc($key, success: $success, ttl: 1);
|
||||
if ($success === true) {
|
||||
return $sequenceId;
|
||||
}
|
||||
|
||||
throw new \Exception('Failed to generate SnowflakeId with APCu');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OC\Snowflake;
|
||||
|
||||
use OCP\ITempManager;
|
||||
use Override;
|
||||
|
||||
class FileSequence implements ISequence {
|
||||
/** Number of files to use */
|
||||
private const NB_FILES = 20;
|
||||
/** Lock filename format **/
|
||||
private const LOCK_FILE_FORMAT = 'seq-%03d.lock';
|
||||
/** Delete sequences after SEQUENCE_TTL seconds **/
|
||||
private const SEQUENCE_TTL = 30;
|
||||
|
||||
private string $workDir;
|
||||
|
||||
public function __construct(
|
||||
ITempManager $tempManager,
|
||||
) {
|
||||
$this->workDir = $tempManager->getTemporaryFolder('.snowflakes');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function isAvailable(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function nextId(int $serverId, int $seconds, int $milliseconds): int {
|
||||
// Open lock file
|
||||
$filePath = $this->getFilePath($milliseconds % self::NB_FILES);
|
||||
$fp = fopen($filePath, 'c+');
|
||||
if ($fp === false) {
|
||||
throw new \Exception('Unable to open sequence ID file: ' . $filePath);
|
||||
}
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
throw new \Exception('Unable to acquire lock on sequence ID file: ' . $filePath);
|
||||
}
|
||||
|
||||
// Read content
|
||||
$content = (string)fgets($fp);
|
||||
$locks = $content === ''
|
||||
? []
|
||||
: json_decode($content, true, 3, JSON_THROW_ON_ERROR);
|
||||
|
||||
// Generate new ID
|
||||
if (isset($locks[$seconds])) {
|
||||
if (isset($locks[$seconds][$milliseconds])) {
|
||||
++$locks[$seconds][$milliseconds];
|
||||
} else {
|
||||
$locks[$seconds][$milliseconds] = 0;
|
||||
}
|
||||
} else {
|
||||
$locks[$seconds] = [
|
||||
$milliseconds => 0
|
||||
];
|
||||
}
|
||||
|
||||
// Clean old sequence IDs
|
||||
$cleanBefore = $seconds - self::SEQUENCE_TTL;
|
||||
$locks = array_filter($locks, static function ($key) use ($cleanBefore) {
|
||||
return $key >= $cleanBefore;
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
// Write data
|
||||
ftruncate($fp, 0);
|
||||
$content = json_encode($locks, JSON_THROW_ON_ERROR);
|
||||
rewind($fp);
|
||||
fwrite($fp, $content);
|
||||
fsync($fp);
|
||||
|
||||
// Release lock
|
||||
fclose($fp);
|
||||
|
||||
return $locks[$seconds][$milliseconds];
|
||||
}
|
||||
|
||||
private function getFilePath(int $fileId): string {
|
||||
return $this->workDir . sprintf(self::LOCK_FILE_FORMAT, $fileId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
namespace OC\Snowflake;
|
||||
|
||||
/**
|
||||
* Generates sequence IDs
|
||||
*/
|
||||
interface ISequence {
|
||||
/**
|
||||
* Check if generator is available
|
||||
*/
|
||||
public function isAvailable(): bool;
|
||||
|
||||
/**
|
||||
* Returns next sequence ID for current time and server
|
||||
*/
|
||||
public function nextId(int $serverId, int $seconds, int $milliseconds): int|false;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Snowflake;
|
||||
|
||||
use OC\Snowflake\APCuSequence;
|
||||
|
||||
/**
|
||||
* @package Test
|
||||
*/
|
||||
class APCuTest extends ISequenceBase {
|
||||
private string $path;
|
||||
|
||||
public function setUp():void {
|
||||
$this->sequence = new APCuSequence();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\Snowflake;
|
||||
|
||||
use OC\Snowflake\FileSequence;
|
||||
use OCP\ITempManager;
|
||||
|
||||
/**
|
||||
* @package Test
|
||||
*/
|
||||
class FileSequenceTest extends ISequenceBase {
|
||||
private string $path;
|
||||
|
||||
public function setUp():void {
|
||||
$tempManager = $this->createMock(ITempManager::class);
|
||||
$this->path = uniqid(sys_get_temp_dir() . '/php_test_seq_', true);
|
||||
mkdir($this->path);
|
||||
$tempManager->method('getTemporaryFolder')->willReturn($this->path);
|
||||
$this->sequence = new FileSequence($tempManager);
|
||||
}
|
||||
|
||||
public function tearDown():void {
|
||||
foreach (glob($this->path . '/*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->path);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue