diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml
new file mode 100644
index 0000000..f4f1239
--- /dev/null
+++ b/.github/workflows/release-acceptance.yml
@@ -0,0 +1,50 @@
+name: UID 6 Release Acceptance
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["main", "master"]
+ paths:
+ - "src/**"
+ - "tests/**"
+ - "composer.json"
+ - ".github/workflows/release-acceptance.yml"
+ pull_request:
+ branches: ["main", "master"]
+ paths:
+ - "src/**"
+ - "tests/**"
+ - "composer.json"
+ - ".github/workflows/release-acceptance.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ production-smoke:
+ name: Production smoke (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.4", "8.5"]
+ steps:
+ - name: Checkout exact candidate
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: composer:v2
+ extensions: ctype
+ coverage: none
+
+ - name: Install production dependencies
+ run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative
+
+ - name: Check every generator and format in one 100-cycle pass
+ run: php tests/smoke.php
diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml
index 7f4e52b..e8fccac 100644
--- a/.github/workflows/security-standards.yml
+++ b/.github/workflows/security-standards.yml
@@ -5,19 +5,29 @@ on:
- cron: "0 0 * * 0"
push:
branches: [ "main", "master" ]
+ tags: [ "v*", "[0-9]*" ]
pull_request:
branches: [ "main", "master", "develop", "development" ]
jobs:
phpforge:
+ if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
uses: infocyph/phpforge/.github/workflows/security-standards.yml@main
with:
fail_on_skipped_tests: true
integration_services: '[]'
service_topologies: '{}'
- php_extensions: '["bcmath"]'
+ php_extensions: '["ctype", "pcntl"]'
permissions:
security-events: write
actions: read
contents: read
+ release:
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
+ uses: infocyph/phpforge/.github/workflows/release.yml@main
+ permissions:
+ contents: write
+ secrets:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+
diff --git a/.gitignore b/.gitignore
index 6991d72..09a9f86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,20 @@
-.idea
-.psalm-cache
-.phpunit.cache
-.vscode
-.windsurf
-.codex
+/.idea
+/.psalm-cache
+/.phpunit.cache
+/.vscode
+/.windsurf
+/.codex
*~
*.patch
*.txt
!docs/requirements.txt
-AI_CONTEXT.md
composer.lock
example
example.php
-git-story_media
+/git-story_media
patch.php
test.php
-var
-vendor
+/var
+/vendor
d2utmp*
+/graphify-out
diff --git a/README.md b/README.md
index e2810f3..9c1b0a2 100644
--- a/README.md
+++ b/README.md
@@ -18,13 +18,16 @@ All-in-one unique ID toolkit for PHP.
- TypeID, ObjectID, NanoID, RandomId, CUID2, KSUID, XID
- Opaque and deterministic IDs
- Value objects and comparator utilities
-- Binary conversion and base encoders (`16`, `32`, `36`, `58`, `62`)
+- Binary conversion and base encoders (`10`, `16`, `32`, `36`, `58`, `62`)
- Pluggable sequence providers (filesystem, memory, PSR-16 cache, callback)
+- Explicit legacy/upstream Sonyflake and Randflake compatibility modes
+- Optional PSR-20 clocks and Runwire 2.1.1+ cooperative wait integration
## Requirements
-- PHP `>=8.2`
+- PHP `>=8.4`
- A 64-bit PHP runtime
+- PHP ctype extension
## Installation
@@ -75,7 +78,7 @@ $decoded = UUID::fromBase($base58, 58);
```
The shared byte-level encoder is available as
-`Infocyph\UID\Support\BaseEncoder` for bases `16`, `32`, `36`, `58`, and `62`.
+`Infocyph\UID\Support\BaseEncoder` for bases `10`, `16`, `32`, `36`, `58`, and `62`.
## References
@@ -98,6 +101,10 @@ UID is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automat
tests, static and taint analysis, dependency auditing, architecture checks, and release readiness. Automated controls reduce
risk but do not replace responsible disclosure or manual review.
+Release acceptance also runs `php tests/smoke.php` with a production-only install on PHP 8.4 and 8.5.
+One 100-cycle pass covers every generator and supported format, checking valid output, sample uniqueness,
+and relevant round trips. Long HTTP benchmarks and soak runs are not release gates.
+
---
diff --git a/TBSL.md b/TBSL.md
index 3b742ec..fac9aa6 100644
--- a/TBSL.md
+++ b/TBSL.md
@@ -23,50 +23,53 @@ Character positions are 1-based.
| Characters | Length | Field | Description |
|:--|--:|:--|:--|
-| 1-15 | 15 hex chars | Time-machine payload | Uppercase hexadecimal encoding of the decimal payload `SSSSSSSSSSUUUUUUMM`, left-padded with zeroes to 15 characters. |
-| 16-20 | 5 hex chars | Entropy or sequence | Random suffix by default, or a sequence suffix when sequenced mode is enabled. |
+| 1-15 | 15 hex chars | Time-machine payload | Uppercase hexadecimal encoding of the 60-bit integer `(unixMicroseconds * 100) + machineId`, left-padded with zeroes to 15 characters. |
+| 16-20 | 5 hex chars | Sequence or entropy | Zero-based sequence suffix by default, or random entropy when sequenced mode is disabled. |
-The decimal payload is composed as follows:
+The time-machine payload is numeric, not a fixed-width decimal string. Parsing is:
-| Decimal digits | Length | Field | Description |
-|:--|--:|:--|:--|
-| 1-10 | 10 digits | Unix seconds | Seconds since the Unix epoch. |
-| 11-16 | 6 digits | Microseconds | Microsecond fraction of the current second. |
-| 17-18 | 2 digits | Machine ID | Machine identifier from `00` to `99`. |
+```text
+unixMicroseconds = intdiv(payload, 100)
+machineId = payload % 100
+```
+
+This representation remains valid when Unix seconds grow beyond ten decimal
+digits, as long as the combined value still fits the 60-bit field.
## Generation
Generation accepts:
- `machineId`: integer from `0` to `99`; default is `0`.
-- `sequenced`: boolean; default is `false`.
+- `sequenced`: boolean; default is `true`. Use `generateRandom()` or `sequenced: false` for the entropy suffix.
The generator:
1. Reads the current Unix time with microsecond precision.
-2. Builds the decimal time sequence as `seconds + microseconds`.
-3. Appends the two-digit machine ID to form `SSSSSSSSSSUUUUUUMM`.
-4. Converts that decimal payload to hexadecimal and left-pads it to 15
- characters.
+2. Converts that time to an integer count of Unix microseconds.
+3. Forms the 60-bit time-machine payload as `(unixMicroseconds * 100) + machineId`.
+4. Converts that integer to hexadecimal and left-pads it to 15 characters.
5. Appends a 5-character hexadecimal suffix:
- - random mode: first 5 hex characters from 3 random bytes;
- - sequenced mode: the next sequence value for the
- `(type = "tbsl", machineId, timestamp)` key, encoded as hex and padded to
- 5 characters.
+ - sequenced mode (default): obtains a positive provider allocation from
+ `(type = "tbsl", machineId, timestamp)`, then encodes
+ `allocation - 1` as five hexadecimal characters;
+ - random mode: first 5 hex characters from 3 random bytes.
6. Returns the 20-character uppercase hexadecimal string.
-Sequence providers should keep returned sequence values within the 20-bit suffix
-range, `0x00000` through `0xFFFFF`.
+In sequenced mode, provider allocations `1..0x100000` map to encoded suffixes
+`00000..FFFFF`. If that range is exhausted for one timestamp, generation waits
+for the next usable timestamp or fails according to the configured bounded-wait
+and clock-backward policy.
## Parsing
To parse a canonical TBSL value:
1. Validate the string against `^[0-9A-F]{20}$`.
-2. Decode characters `1-15` from hexadecimal to the decimal payload.
-3. Read the first 10 decimal digits as Unix seconds.
-4. Read the next 6 decimal digits as microseconds.
-5. Read the final 2 decimal digits as the machine ID.
+2. Decode characters `1-15` from hexadecimal to the 60-bit payload.
+3. Recover Unix microseconds with `intdiv(payload, 100)`.
+4. Recover the machine ID with `payload % 100`.
+5. Build the timestamp from the recovered seconds and microsecond fraction.
The suffix is intentionally opaque. It is not needed to recover the timestamp or
machine ID.
@@ -80,11 +83,12 @@ move forward.
For IDs generated within the same microsecond and machine ID:
- random mode provides uniqueness through entropy, but not generation order;
-- sequenced mode provides deterministic suffix ordering while the sequence value
- remains within the 5-character hexadecimal suffix.
+- sequenced mode provides deterministic suffix ordering while the sequence range
+ remains available.
If the clock moves backward, the implementation either waits for the next usable
time sequence or throws, depending on the configured clock-backward policy.
+Waits are bounded; a frozen injected clock cannot spin indefinitely.
## Encodings
@@ -92,6 +96,7 @@ The canonical representation is uppercase hexadecimal. The package can also
convert canonical TBSL values to and from:
- raw 10-byte binary;
+- base10;
- base16;
- base32;
- base36;
@@ -105,7 +110,8 @@ canonical 20-character uppercase hexadecimal TBSL string.
- Canonical size: 20 hex characters.
- Binary size: 10 bytes.
+- Time-machine payload: 60 bits.
- Timestamp precision: microseconds.
- Machine ID range: `0` through `99`.
- Suffix size: 5 hex characters, or 20 bits.
-- Maximum suffix cardinality per `(timestamp, machineId)` key: 1,048,576 values.
+- Sequenced suffix cardinality per `(timestamp, machineId)` key: 1,048,576 values.
diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php
index d1135f3..f424983 100644
--- a/benchmarks/BaseCodecBench.php
+++ b/benchmarks/BaseCodecBench.php
@@ -5,20 +5,40 @@
namespace Infocyph\UID\Benchmarks;
use Infocyph\UID\Support\BaseEncoder;
+use Infocyph\UID\Support\DecimalBytes;
+use Infocyph\UID\Support\NumericIdCodec;
+use Infocyph\UID\Support\TypeIdCodec;
use PhpBench\Attributes as Bench;
final class BaseCodecBench
{
+ /** @var array
*/
+ private array $decimal = [];
+
+ /** @var array> */
+ private array $encoded = [];
+
/** @var array */
private array $samples = [];
+ private string $typeIdEncoded;
+
public function __construct()
{
require_once __DIR__ . '/BenchBootstrap.php';
BenchBootstrap::load();
- foreach ([8, 10, 12, 16, 20, 32] as $length) {
- $this->samples[$length] = random_bytes($length);
+
+ foreach ([8, 10, 12, 16, 20, 32, 64] as $length) {
+ $sample = random_bytes($length);
+ $this->samples[$length] = $sample;
+ $this->decimal[$length] = DecimalBytes::fromBytes($sample);
+
+ foreach ([10, 16, 32, 36, 58, 62] as $base) {
+ $this->encoded[$base][$length] = BaseEncoder::encodeBytes($sample, $base);
+ }
}
+
+ $this->typeIdEncoded = TypeIdCodec::encode($this->samples[16]);
}
#[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')]
@@ -57,6 +77,59 @@ public function benchDecimal(array $params): void
BaseEncoder::encodeBytes($this->sample($params), 10);
}
+ #[Bench\Revs(250), Bench\Iterations(5), Bench\ParamProviders('provideBaseLengthPairs')]
+ public function benchDecode(array $params): void
+ {
+ BaseEncoder::decodeToBytes(
+ $this->encoded[$params['base']][$params['length']],
+ $params['base'],
+ $params['length'],
+ );
+ }
+
+ #[Bench\Revs(500), Bench\Iterations(5), Bench\ParamProviders('provideLengths')]
+ public function benchNumericFromBytes(array $params): void
+ {
+ NumericIdCodec::decimalFromBytes($this->sample($params), $params['length']);
+ }
+
+ #[Bench\Revs(500), Bench\Iterations(5), Bench\ParamProviders('provideLengths')]
+ public function benchNumericToBytes(array $params): void
+ {
+ DecimalBytes::toFixedBytes($this->decimal[$params['length']], $params['length']);
+ }
+
+ #[Bench\Revs(1000), Bench\Iterations(5)]
+ public function benchTypeIdDecode(): void
+ {
+ TypeIdCodec::decode($this->typeIdEncoded);
+ }
+
+ #[Bench\Revs(1000), Bench\Iterations(5)]
+ public function benchTypeIdEncode(): void
+ {
+ TypeIdCodec::encode($this->samples[16]);
+ }
+
+ /**
+ * @return array
+ */
+ public function provideBaseLengthPairs(): array
+ {
+ $pairs = [];
+
+ foreach ([10, 16, 32, 36, 58, 62] as $base) {
+ foreach ([8, 10, 12, 16, 20, 32] as $length) {
+ $pairs['base-' . $base . '-' . $length . '-bytes'] = [
+ 'base' => $base,
+ 'length' => $length,
+ ];
+ }
+ }
+
+ return $pairs;
+ }
+
/**
* @return array
*/
@@ -69,6 +142,7 @@ public function provideLengths(): array
'16-bytes' => ['length' => 16],
'20-bytes' => ['length' => 20],
'32-bytes' => ['length' => 32],
+ '64-bytes' => ['length' => 64],
];
}
diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php
index f50f9f1..9f0b5b9 100644
--- a/benchmarks/HotspotBench.php
+++ b/benchmarks/HotspotBench.php
@@ -4,6 +4,7 @@
namespace Infocyph\UID\Benchmarks;
+use Closure;
use Infocyph\UID\Configuration\RandflakeConfig;
use Infocyph\UID\Configuration\SnowflakeConfig;
use Infocyph\UID\Configuration\SonyflakeConfig;
@@ -28,10 +29,14 @@
final class HotspotBench
{
+ private Closure $cuidFingerprint;
+
private string $opaque;
private RandflakeConfig $randflakeConfig;
+ private Closure $resetCuidFingerprint;
+
private SnowflakeConfig $snowflakeConfig;
private SonyflakeConfig $sonyflakeConfig;
@@ -43,6 +48,26 @@ public function __construct()
require_once __DIR__ . '/BenchBootstrap.php';
BenchBootstrap::load();
+ $fingerprint = Closure::bind(
+ static fn(): string => CUID2::fingerprint(),
+ null,
+ CUID2::class,
+ );
+ $resetFingerprint = Closure::bind(
+ static function (): void {
+ CUID2::$fingerprint = null;
+ },
+ null,
+ CUID2::class,
+ );
+ if (!$fingerprint instanceof Closure || !$resetFingerprint instanceof Closure) {
+ throw new \LogicException('Unable to bind CUID2 benchmark helpers');
+ }
+
+ $this->cuidFingerprint = $fingerprint;
+ $this->resetCuidFingerprint = $resetFingerprint;
+ ($this->cuidFingerprint)();
+
$provider = new FilesystemSequenceProvider(namespace: 'phpbench');
[$leaseStart, $leaseEnd, $secret] = BenchBootstrap::randflakeContext();
$this->snowflakeConfig = new SnowflakeConfig(sequenceProvider: $provider);
@@ -58,6 +83,26 @@ public function benchCuid2(): void
CUID2::generate();
}
+ #[Bench\Revs(100), Bench\Iterations(5)]
+ public function benchCuid2ColdGenerate(): void
+ {
+ ($this->resetCuidFingerprint)();
+ CUID2::generate();
+ }
+
+ #[Bench\Revs(100), Bench\Iterations(5)]
+ public function benchCuid2FingerprintCold(): void
+ {
+ ($this->resetCuidFingerprint)();
+ ($this->cuidFingerprint)();
+ }
+
+ #[Bench\Revs(1000), Bench\Iterations(5)]
+ public function benchCuid2FingerprintWarm(): void
+ {
+ ($this->cuidFingerprint)();
+ }
+
#[Bench\Revs(1000), Bench\Iterations(5)]
public function benchDeterministicId(): void
{
diff --git a/composer.json b/composer.json
index 92e9d72..da0c90f 100644
--- a/composer.json
+++ b/composer.json
@@ -31,17 +31,22 @@
}
],
"require": {
- "php": "^8.2",
- "php-64bit": "^8.2"
+ "php": "^8.4",
+ "php-64bit": "^8.4",
+ "ext-ctype": "*"
},
"require-dev": {
"infocyph/phpforge": "dev-main@dev",
+ "infocyph/runwire": "^2.1.1",
+ "psr/clock": "^1.0",
"psr/simple-cache": "^3.0"
},
"replace": {
"abmmhasan/uuid": "self.version"
},
"suggest": {
+ "infocyph/runwire": "Enables request-aware cooperative waits for coordinated generators.",
+ "psr/clock": "Enables injectable wall-clock sampling for coordinated generators.",
"psr/simple-cache": "Required for the optional PSR-16 sequence provider."
},
"minimum-stability": "stable",
diff --git a/docs/base-encoding.rst b/docs/base-encoding.rst
index a92daf9..be78b3d 100644
--- a/docs/base-encoding.rst
+++ b/docs/base-encoding.rst
@@ -7,6 +7,7 @@ UID exposes base conversion through algorithm-specific APIs and a shared
Supported Bases
---------------
+- ``10``: decimal alphabet
- ``16``: lowercase hexadecimal alphabet
- ``32``: ``0-9a-v`` alphabet
- ``36``: ``0-9a-z`` alphabet
@@ -23,6 +24,7 @@ the canonical input and restore the canonical output type:
- ``ULID::toBase($ulid, $base)`` / ``ULID::fromBase($encoded, $base)``
- ``Snowflake::toBase($id, $base)`` / ``Snowflake::fromBase($encoded, $base)``
- ``Sonyflake::toBase($id, $base)`` / ``Sonyflake::fromBase($encoded, $base)``
+- ``Randflake::toBase($id, $base, $format)`` / ``Randflake::fromBase($encoded, $base, $format)``
- ``TBSL::toBase($id, $base)`` / ``TBSL::fromBase($encoded, $base)``
.. code-block:: php
@@ -56,5 +58,7 @@ left-padded and validated consistently.
Notes
-----
+Base-10 values are also transport encodings of the underlying bytes; for structured numeric ID families, prefer the algorithm-specific canonical decimal representation when one exists.
+
Alternate-base values are transport encodings. They do not replace each
algorithm's canonical representation.
diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst
index 541ce19..af57ab5 100644
--- a/docs/benchmark-report.rst
+++ b/docs/benchmark-report.rst
@@ -62,9 +62,25 @@ inside it. Those measurements are not comparable algorithm baselines, so no
misleading percentage delta is reported. This report is the first isolated v5
baseline; future releases should compare against it using the checked-in harness.
-Correctness gates took priority over throughput: the multi-process test suite
-for Snowflake, Sonyflake, Randflake, TBSL, and sequence reservations produced
-zero duplicates and zero lock errors.
+Current Release Checks
+----------------------
+
+Release acceptance runs one 100-cycle smoke pass across every generator and
+supported format on PHP 8.4 and 8.5, using a production-only Composer install.
+It checks valid output, no repeated IDs within each sample, relevant binary
+round trips, configured node fields and deterministic results. Run it locally
+with ``php tests/smoke.php``.
+
+The Security & Standards workflow retains the full test suite, static and taint
+analysis, dependency audit, and process/fork, cancellation, exhaustion and
+malformed-input regressions. The smoke pass is an additional consumer check;
+it does not establish collision probability, cryptographic security or sustained
+host throughput.
+
+The former long HTTP performance matrix and persistent-worker soak are removed.
+There is no mandatory RPM comparison against tag ``5.0``. Existing component
+benchmark tools remain available for targeted investigations; the historical
+v5 measurements above are not v6 performance claims.
Filesystem Contention
---------------------
diff --git a/docs/compatibility.rst b/docs/compatibility.rst
index 614790d..c3a8b84 100644
--- a/docs/compatibility.rst
+++ b/docs/compatibility.rst
@@ -10,8 +10,11 @@ Format Compatibility
- ObjectID implements the BSON 12-byte ObjectID layout and canonical lowercase hexadecimal text.
- ULID uses canonical Crockford Base32 and supports random and process-local monotonic modes.
- Snowflake uses a 41/5/5/12 signed 64-bit layout.
-- Sonyflake uses a 39/16/8 signed 64-bit layout with 10 millisecond timestamps.
-- Randflake uses an unsigned 64-bit 30/17/17 layout before keyed permutation.
+- Sonyflake defaults to UID's 39/16/8 time/machine/sequence layout and also
+ supports explicit upstream 39/8/16 time/sequence/machine mode.
+- Randflake defaults to UID's unsigned 30/17/17 payload plus legacy Feistel
+ representation and also supports explicit upstream SPARX64 representation,
+ signed-decimal storage, and upstream Base32hex text.
- TBSL is a project-specific 10-byte, uppercase hexadecimal format.
- KSUID and XID retain their standard fixed-length text and binary layouts.
@@ -61,6 +64,8 @@ Always enforce authorization independently of identifier format.
Runtime Requirements
--------------------
-- PHP 8.2 or newer on a 64-bit runtime.
+- PHP 8.4 or newer on a 64-bit runtime.
+- The ctype extension is required.
+- Runwire 2.1.1+ and PSR-20 clocks are optional passed-instance integrations.
- No BCMath dependency.
- PSR-16 is optional and needed only for the PSR simple-cache sequence provider.
diff --git a/docs/db-storage.rst b/docs/db-storage.rst
index 6ae841d..30a4a2a 100644
--- a/docs/db-storage.rst
+++ b/docs/db-storage.rst
@@ -1,7 +1,7 @@
Database Storage
================
-Storage recommendations are documentation only; v5 has no runtime ``DbStorage`` API.
+Storage recommendations are documentation only; UID has no runtime ``DbStorage`` API.
.. list-table::
:header-rows: 1
@@ -24,9 +24,12 @@ Storage recommendations are documentation only; v5 has no runtime ``DbStorage``
* - Snowflake/Sonyflake
- ``BIGINT``
- ``BIGINT``
- * - Randflake
+ * - Randflake (UID format)
- ``BIGINT UNSIGNED`` / ``BINARY(8)``
- ``NUMERIC(20,0)`` / ``BYTEA``
+ * - Randflake (upstream format)
+ - signed ``BIGINT`` / ``BINARY(8)``
+ - signed ``BIGINT`` / ``BYTEA``
* - TBSL
- ``CHAR(20)`` / ``BINARY(10)``
- ``CHAR(20)`` / ``BYTEA``
@@ -38,3 +41,5 @@ the entity type in the schema and store the underlying UUID bytes.
An epoch is part of a deployed Snowflake or Sonyflake ID domain. Changing it
creates a different domain and may eventually produce values overlapping the
original domain.
+
+Randflake storage must preserve the format discriminator outside the raw value when an application can contain both UID and upstream-compatible representations. The upstream representation is signed 64-bit; the legacy UID representation can require the full unsigned 64-bit domain.
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
index 96bb7bb..4a60dd2 100644
--- a/docs/exceptions.rst
+++ b/docs/exceptions.rst
@@ -9,7 +9,9 @@ Hierarchy
- ``Infocyph\\UID\\Exceptions\\ULIDException``
- ``Infocyph\\UID\\Exceptions\\SnowflakeException``
- ``Infocyph\\UID\\Exceptions\\SonyflakeException``
+- ``Infocyph\\UID\\Exceptions\\RandflakeException``
- ``Infocyph\\UID\\Exceptions\\FileLockException``
+- ``Infocyph\\UID\\Exceptions\\SequenceTimestampException`` (extends ``FileLockException``)
Usage Pattern
-------------
diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst
index 4cd2772..5f930b2 100644
--- a/docs/framework-integration.rst
+++ b/docs/framework-integration.rst
@@ -6,35 +6,82 @@ Laravel
- Helper functions are available through Composer autoload.
- Prefer UUIDv7 or ULID for ordered primary keys.
-- Keep IDs as strings in application boundaries; convert to binary only at persistence edges.
+- Keep IDs as strings at application boundaries; convert to binary only at persistence edges.
Symfony
-------
- Helper functions are available through Composer autoload.
-- Prefer central generation through ``Infocyph\\UID\\Id`` inside services.
+- Prefer central generation through Infocyph\\UID\\Id inside services.
Generic PHP Apps
----------------
-- Use ``Id::nanoId()`` for short public IDs.
-- Use ``Id::deterministic()`` for stable IDs from payloads.
-- Use config objects for policy/output tuning:
+Use config objects for coordinated generators and keep them scoped to the
+application domain that owns their node/machine ID, epoch, sequence provider and
+runtime policy. SnowflakeConfig, SonyflakeConfig, RandflakeConfig and TBSLConfig
+may receive a GenerationContext without changing the ordinary synchronous APIs.
- - ``SnowflakeConfig``
- - ``SonyflakeConfig``
- - ``TBSLConfig``
+Runwire 2.1.1+ Integration
+--------------------------
-- Use value objects for richer domain models:
+Runwire support is optional. UID never discovers a runtime globally and never
+starts, stops, drives or closes a Runwire runtime, request, scope or event loop.
+The host passes the exact runtime/request/scope instances that already own the
+operation.
- - ``UuidValue``
- - ``UlidValue``
- - ``SnowflakeValue``
- - ``SonyflakeValue``
- - ``TbslValue``
+.. code-block:: php
-Distributed Sequence Coordination
----------------------------------
+ leaseEndExclusive !== null) {
+ return $this->leaseEndExclusive;
+ }
+ if ($this->leaseEnd === PHP_INT_MAX) {
+ throw new RandflakeException('randflake: inclusive lease end cannot be converted to an exclusive boundary');
+ }
+
+ return $this->leaseEnd + 1;
+ }
}
diff --git a/src/Configuration/ResolvesCustomEpoch.php b/src/Configuration/ResolvesCustomEpoch.php
index 1d457e9..add2a8d 100644
--- a/src/Configuration/ResolvesCustomEpoch.php
+++ b/src/Configuration/ResolvesCustomEpoch.php
@@ -10,19 +10,17 @@ trait ResolvesCustomEpoch
{
public function resolveCustomEpochMs(): ?int
{
- return self::resolveEpochValue($this->customEpoch);
+ return $this->customEpoch;
}
- private static function resolveEpochValue(DateTimeInterface|int|null $customEpoch): ?int
+ private static function normalizeEpoch(DateTimeInterface|int|null $customEpoch): ?int
{
if ($customEpoch === null) {
return null;
}
- if ($customEpoch instanceof DateTimeInterface) {
- return (int) $customEpoch->format('Uv');
- }
-
- return $customEpoch;
+ return $customEpoch instanceof DateTimeInterface
+ ? (int) $customEpoch->format('Uv')
+ : $customEpoch;
}
}
diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php
index 9e8f01a..21e5740 100644
--- a/src/Configuration/SnowflakeConfig.php
+++ b/src/Configuration/SnowflakeConfig.php
@@ -7,12 +7,15 @@
use Closure;
use DateTimeInterface;
use Infocyph\UID\Enums\ClockBackwardPolicy;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\SequenceProviderInterface;
final readonly class SnowflakeConfig
{
use ResolvesCustomEpoch;
+ public ?int $customEpoch;
+
private ?Closure $nodeResolver;
/**
@@ -23,11 +26,13 @@ public function __construct(
public int $datacenterId = 0,
public int $workerId = 0,
?callable $nodeResolver = null,
- public DateTimeInterface|int|null $customEpoch = null,
+ DateTimeInterface|int|null $customEpoch = null,
public ?SequenceProviderInterface $sequenceProvider = null,
public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT,
+ public ?GenerationContext $runtime = null,
) {
$this->nodeResolver = $nodeResolver ? $nodeResolver(...) : null;
+ $this->customEpoch = self::normalizeEpoch($customEpoch);
}
/**
diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php
index 03aadab..f494ffe 100644
--- a/src/Configuration/SonyflakeConfig.php
+++ b/src/Configuration/SonyflakeConfig.php
@@ -6,6 +6,8 @@
use DateTimeInterface;
use Infocyph\UID\Enums\ClockBackwardPolicy;
+use Infocyph\UID\Enums\SonyflakeFormat;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\SequenceProviderInterface;
final readonly class SonyflakeConfig
@@ -13,16 +15,21 @@
use ResolvesCustomEpoch;
use ResolvesMachineId;
+ public ?int $customEpoch;
+
/**
* @param callable():mixed|null $machineIdResolver
*/
public function __construct(
public int $machineId = 0,
?callable $machineIdResolver = null,
- public DateTimeInterface|int|null $customEpoch = null,
+ DateTimeInterface|int|null $customEpoch = null,
public ?SequenceProviderInterface $sequenceProvider = null,
public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT,
+ public ?GenerationContext $runtime = null,
+ public SonyflakeFormat $format = SonyflakeFormat::UID,
) {
$this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null;
+ $this->customEpoch = self::normalizeEpoch($customEpoch);
}
}
diff --git a/src/Configuration/TBSLConfig.php b/src/Configuration/TBSLConfig.php
index c3200c0..3458c95 100644
--- a/src/Configuration/TBSLConfig.php
+++ b/src/Configuration/TBSLConfig.php
@@ -5,6 +5,7 @@
namespace Infocyph\UID\Configuration;
use Infocyph\UID\Enums\ClockBackwardPolicy;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\SequenceProviderInterface;
final readonly class TBSLConfig
@@ -20,6 +21,7 @@ public function __construct(
?callable $machineIdResolver = null,
public ?SequenceProviderInterface $sequenceProvider = null,
public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT,
+ public ?GenerationContext $runtime = null,
) {
$this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null;
}
diff --git a/src/DeterministicId.php b/src/DeterministicId.php
index 7ad9e6c..2f489bd 100644
--- a/src/DeterministicId.php
+++ b/src/DeterministicId.php
@@ -8,11 +8,11 @@
final class DeterministicId
{
- private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+ private const string ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
- private const DOMAIN = "infocyph.uid.deterministic.v5\0";
+ private const string DOMAIN = "infocyph.uid.deterministic.v5\0";
- private const MAX_LENGTH = 43;
+ private const int MAX_LENGTH = 43;
/**
* Generates a deterministic opaque ID from payload.
diff --git a/src/Enums/RandflakeFormat.php b/src/Enums/RandflakeFormat.php
new file mode 100644
index 0000000..cafeae5
--- /dev/null
+++ b/src/Enums/RandflakeFormat.php
@@ -0,0 +1,12 @@
+resolveCustomEpochMs());
+ $format = SonyflakeFormat::UID;
+ if ($config !== null) {
+ $format = $config->format;
+ }
+
+ return new SonyflakeValue(
+ self::sonyflake($config),
+ $config?->resolveCustomEpochMs(),
+ $format,
+ );
}
public static function tbsl(?TBSLConfig $config = null): string
diff --git a/src/IdComparator.php b/src/IdComparator.php
index 0cabd13..4032f96 100644
--- a/src/IdComparator.php
+++ b/src/IdComparator.php
@@ -17,7 +17,12 @@ public static function compare(IdValueInterface|string $left, IdValueInterface|s
$leftString = $left instanceof IdValueInterface ? $left->toString() : $left;
$rightString = $right instanceof IdValueInterface ? $right->toString() : $right;
- if (ctype_digit($leftString) && ctype_digit($rightString)) {
+ $leftNumeric = ctype_digit($leftString);
+ $rightNumeric = ctype_digit($rightString);
+ if ($leftNumeric !== $rightNumeric) {
+ return $leftNumeric ? -1 : 1;
+ }
+ if ($leftNumeric) {
return UnsignedDecimal::compare($leftString, $rightString);
}
diff --git a/src/KSUID.php b/src/KSUID.php
index 613daf2..a3a8bb9 100644
--- a/src/KSUID.php
+++ b/src/KSUID.php
@@ -13,11 +13,11 @@
final class KSUID
{
- private const EPOCH = 1_400_000_000;
+ private const int EPOCH = 1_400_000_000;
- private const MAX_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V';
+ private const string MAX_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V';
- private const MAX_TIMESTAMP_OFFSET = 0xffffffff;
+ private const int MAX_TIMESTAMP_OFFSET = 0xffffffff;
/**
* @throws Exception
diff --git a/src/NanoID.php b/src/NanoID.php
index 75df183..82be887 100644
--- a/src/NanoID.php
+++ b/src/NanoID.php
@@ -9,7 +9,7 @@
final class NanoID
{
- private const MAX_LENGTH = 1024;
+ private const int MAX_LENGTH = 1024;
/**
* Generates a NanoID string with the requested size.
@@ -43,7 +43,7 @@ public static function isValid(string $id, ?int $length = null): bool
return false;
}
- return preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1;
+ return preg_match('/^[A-Za-z0-9_-]+$/D', $id) === 1;
}
/**
diff --git a/src/OpaqueId.php b/src/OpaqueId.php
index 20a62a2..f5262c2 100644
--- a/src/OpaqueId.php
+++ b/src/OpaqueId.php
@@ -38,7 +38,11 @@ public static function toInt(string $token, string $salt = ''): int
$value = $unpacked[1] ?? null;
is_int($value) || throw new Exception('Unable to decode opaque token');
$saltMask = crc32($salt);
+ $decoded = $value ^ $saltMask;
+ if ($decoded < 0) {
+ throw new \InvalidArgumentException('Decoded opaque token is outside the supported non-negative domain');
+ }
- return $value ^ $saltMask;
+ return $decoded;
}
}
diff --git a/src/Randflake.php b/src/Randflake.php
index 081603b..c3587bf 100644
--- a/src/Randflake.php
+++ b/src/Randflake.php
@@ -7,77 +7,133 @@
use DateTimeImmutable;
use Exception;
use Infocyph\UID\Configuration\RandflakeConfig;
+use Infocyph\UID\Enums\RandflakeFormat;
use Infocyph\UID\Exceptions\FileLockException;
use Infocyph\UID\Exceptions\RandflakeException;
use Infocyph\UID\Exceptions\SequenceTimestampException;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\FilesystemSequenceProvider;
use Infocyph\UID\Sequence\SequenceProviderInterface;
use Infocyph\UID\Support\BaseEncoder;
use Infocyph\UID\Support\DecimalBytes;
use Infocyph\UID\Support\GetSequence;
use Infocyph\UID\Support\NumericConversion;
+use Infocyph\UID\Support\Sparx64;
use Infocyph\UID\Support\UnsignedDecimal;
final class Randflake
{
use GetSequence;
- public const EPOCH_OFFSET = 1_730_000_000;
+ public const int EPOCH_OFFSET = 1_730_000_000;
- public const MAX_NODE = (1 << self::NODE_BITS) - 1;
+ public const int MAX_NODE = (1 << self::NODE_BITS) - 1;
- public const MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1;
+ public const int MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1;
- public const MAX_TIMESTAMP = self::EPOCH_OFFSET + self::MAX_TIMESTAMP_PART;
+ public const int MAX_TIMESTAMP = self::EPOCH_OFFSET + self::MAX_TIMESTAMP_PART;
- public const MAX_TIMESTAMP_PART = (1 << self::TIMESTAMP_BITS) - 1;
+ public const int MAX_TIMESTAMP_PART = (1 << self::TIMESTAMP_BITS) - 1;
- public const NODE_BITS = 17;
+ public const int NODE_BITS = 17;
- public const SEQUENCE_BITS = 17;
+ public const int SEQUENCE_BITS = 17;
- public const TIMESTAMP_BITS = 30;
+ public const int TIMESTAMP_BITS = 30;
- /** @var \WeakMap>|null */
+ private const int MAX_PROVIDER_DOMAINS = 1024;
+
+ /** @var \WeakMap>|null */
private static ?\WeakMap $lastTimestampByProvider = null;
/** @var array> */
private static array $roundKeyCache = [];
+ /** @var array */
+ private static array $sparxCache = [];
+
/**
* @throws RandflakeException
*/
- public static function decodeString(string $id): string
- {
+ public static function decodeString(
+ string $id,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ if (
+ preg_match('/^(?:0|[1-9a-v][0-9a-v]{0,12})$/D', $id) !== 1
+ || (strlen($id) > 1 && $id[0] === '0')
+ ) {
+ throw new RandflakeException('randflake: invalid id');
+ }
+
+ try {
+ $bytes = BaseEncoder::decodeToBytes($id, 32, 8);
+ $decoded = DecimalBytes::fromLittleEndianSigned64(strrev($bytes));
+ } catch (\InvalidArgumentException $exception) {
+ throw new RandflakeException('randflake: invalid id', 0, $exception);
+ }
+
+ if (self::encodeString($decoded, $format) !== $id) {
+ throw new RandflakeException('randflake: invalid id');
+ }
+
+ return $decoded;
+ }
+
return NumericConversion::decimalFromBase(
$id,
32,
8,
- static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException(
- $message === '' ? 'randflake: invalid id' : 'randflake: invalid id',
- 0,
- $exception,
- ),
+ static function (string $message, \InvalidArgumentException $exception): RandflakeException {
+ unset($message);
+
+ return new RandflakeException('randflake: invalid id', 0, $exception);
+ },
);
}
/**
* @throws RandflakeException
*/
- public static function encodeString(string $id): string
- {
- if (!self::isValid($id)) {
+ public static function encodeString(
+ string $id,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if (!self::isValid($id, $format)) {
throw new RandflakeException('randflake: invalid id');
}
- return BaseEncoder::encodeBytes(self::toBytes($id), 32);
+ $bytes = self::toBytes($id, $format);
+
+ return BaseEncoder::encodeBytes(
+ $format === RandflakeFormat::UPSTREAM ? strrev($bytes) : $bytes,
+ 32,
+ );
}
/**
* @throws RandflakeException
*/
- public static function fromBase(string $encoded, int $base): string
- {
+ public static function fromBase(
+ string $encoded,
+ int $base,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ if ($base === 32) {
+ return self::decodeString($encoded, $format);
+ }
+
+ try {
+ return DecimalBytes::fromLittleEndianSigned64(
+ strrev(BaseEncoder::decodeToBytes($encoded, $base, 8)),
+ );
+ } catch (\InvalidArgumentException $exception) {
+ throw new RandflakeException('randflake: invalid id', 0, $exception);
+ }
+ }
+
return NumericConversion::decimalFromBase(
$encoded,
$base,
@@ -89,8 +145,18 @@ public static function fromBase(string $encoded, int $base): string
/**
* @throws RandflakeException
*/
- public static function fromBytes(string $bytes): string
- {
+ public static function fromBytes(
+ string $bytes,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ try {
+ return DecimalBytes::fromLittleEndianSigned64($bytes);
+ } catch (\InvalidArgumentException $exception) {
+ throw new RandflakeException('randflake: invalid id', 0, $exception);
+ }
+ }
+
return NumericConversion::decimalFromBytes(
$bytes,
8,
@@ -102,22 +168,35 @@ public static function fromBytes(string $bytes): string
/**
* @throws RandflakeException|FileLockException
*/
- public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string
- {
+ public static function generate(
+ int $nodeId,
+ int $leaseStart,
+ int $leaseEnd,
+ #[\SensitiveParameter]
+ string $secret,
+ ): string {
return self::generateInternal(
$nodeId,
$leaseStart,
$leaseEnd,
$secret,
null,
+ null,
+ RandflakeFormat::UID,
+ null,
);
}
/**
* @throws RandflakeException|FileLockException
*/
- public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string
- {
+ public static function generateString(
+ int $nodeId,
+ int $leaseStart,
+ int $leaseEnd,
+ #[\SensitiveParameter]
+ string $secret,
+ ): string {
return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret));
}
@@ -132,6 +211,11 @@ public static function generateWithConfig(RandflakeConfig $config): string
$config->leaseEnd,
$config->secret,
$config->sequenceProvider,
+ $config->runtime,
+ $config->format,
+ $config->format === RandflakeFormat::UPSTREAM
+ ? $config->resolveLeaseEndExclusive()
+ : null,
);
}
@@ -139,15 +223,20 @@ public static function generateWithConfig(RandflakeConfig $config): string
* @return array{timestamp: int, node_id: int, sequence: int}
* @throws RandflakeException
*/
- public static function inspect(string $id, string $secret): array
- {
- if (!self::isValid($id)) {
+ public static function inspect(
+ string $id,
+ #[\SensitiveParameter]
+ string $secret,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): array {
+ if (!self::isValid($id, $format)) {
throw new RandflakeException('randflake: invalid id');
}
[$timestamp, $nodeId, $sequence] = self::inspectBytes(
- self::toBytes($id),
+ self::toBytes($id, $format),
self::validateSecret($secret),
+ $format,
);
return [
@@ -161,13 +250,23 @@ public static function inspect(string $id, string $secret): array
* @return array{timestamp: int, node_id: int, sequence: int}
* @throws RandflakeException
*/
- public static function inspectString(string $id, string $secret): array
- {
- return self::inspect(self::decodeString($id), $secret);
+ public static function inspectString(
+ string $id,
+ #[\SensitiveParameter]
+ string $secret,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): array {
+ return self::inspect(self::decodeString($id, $format), $secret, $format);
}
- public static function isValid(string $id): bool
- {
+ public static function isValid(
+ string $id,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): bool {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ return DecimalBytes::isSigned64($id);
+ }
+
return $id !== ''
&& ctype_digit($id)
&& UnsignedDecimal::compare($id, '18446744073709551615') <= 0;
@@ -177,15 +276,20 @@ public static function isValid(string $id): bool
* @return array{time: DateTimeImmutable, node_id: int, sequence: int}
* @throws Exception
*/
- public static function parse(string $id, string $secret): array
- {
- if (!self::isValid($id)) {
+ public static function parse(
+ string $id,
+ #[\SensitiveParameter]
+ string $secret,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): array {
+ if (!self::isValid($id, $format)) {
throw new RandflakeException('randflake: invalid id');
}
[$timestamp, $nodeId, $sequence] = self::inspectBytes(
- self::toBytes($id),
+ self::toBytes($id, $format),
self::validateSecret($secret),
+ $format,
);
return [
@@ -199,28 +303,54 @@ public static function parse(string $id, string $secret): array
* @return array{time: DateTimeImmutable, node_id: int, sequence: int}
* @throws Exception
*/
- public static function parseString(string $id, string $secret): array
- {
- return self::parse(self::decodeString($id), $secret);
+ public static function parseString(
+ string $id,
+ #[\SensitiveParameter]
+ string $secret,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): array {
+ return self::parse(self::decodeString($id, $format), $secret, $format);
}
/**
* @throws RandflakeException
*/
- public static function toBase(string $id, int $base): string
- {
- return BaseEncoder::encodeBytes(self::toBytes($id), $base);
+ public static function toBase(
+ string $id,
+ int $base,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if ($format === RandflakeFormat::UPSTREAM && $base === 32) {
+ return self::encodeString($id, $format);
+ }
+
+ $bytes = self::toBytes($id, $format);
+
+ return BaseEncoder::encodeBytes(
+ $format === RandflakeFormat::UPSTREAM ? strrev($bytes) : $bytes,
+ $base,
+ );
}
/**
* @throws RandflakeException
*/
- public static function toBytes(string $id): string
- {
+ public static function toBytes(
+ string $id,
+ RandflakeFormat $format = RandflakeFormat::UID,
+ ): string {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ try {
+ return DecimalBytes::toLittleEndianSigned64($id);
+ } catch (\InvalidArgumentException $exception) {
+ throw new RandflakeException('randflake: invalid id', 0, $exception);
+ }
+ }
+
return NumericConversion::bytesFromDecimal(
$id,
8,
- self::isValid(...),
+ static fn(string $value): bool => self::isValid($value, RandflakeFormat::UID),
'randflake: invalid id',
'randflake: invalid id',
static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException($message, 0, $exception),
@@ -228,40 +358,25 @@ public static function toBytes(string $id): string
}
/**
- * @throws RandflakeException|FileLockException
+ * @return array{0:int,1:int}
*/
- private static function generateInternal(
+ private static function allocateSequence(
+ SequenceProviderInterface $provider,
int $nodeId,
+ int $now,
int $leaseStart,
int $leaseEnd,
- string $secret,
- ?SequenceProviderInterface $sequenceProvider,
- ): string {
- self::validateNode($nodeId);
- self::validateLeaseWindow($leaseStart, $leaseEnd);
- $secret = self::validateSecret($secret);
-
- $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider);
- self::$lastTimestampByProvider ??= new \WeakMap();
- $providerState = self::$lastTimestampByProvider[$resolvedSequenceProvider] ??= new \ArrayObject();
- $now = time();
- if ($now < $leaseStart || $now > $leaseEnd) {
- throw new RandflakeException('randflake: invalid lease, lease expired or not started yet');
- }
-
- if ($now > self::MAX_TIMESTAMP) {
- throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime');
- }
-
- $lastTimestamp = $providerState[$nodeId] ?? null;
- if ($lastTimestamp !== null && $now < $lastTimestamp) {
- throw new RandflakeException('randflake: timestamp consistency violation, the current time is less than the last time');
- }
+ RandflakeFormat $format,
+ ?int $leaseEndExclusive,
+ ?GenerationContext $runtime,
+ ): array {
+ $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream';
try {
- $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider);
+ return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)];
} catch (SequenceTimestampException $exception) {
- $now = time();
+ $now = self::nowSeconds($runtime);
+ self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive);
if ($now < $exception->lastTimestamp) {
throw new RandflakeException(
'randflake: timestamp consistency violation, the current time is less than the persisted time',
@@ -270,34 +385,104 @@ private static function generateInternal(
);
}
- $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider);
+ return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)];
}
- if ($sequenceValue < 1) {
- throw new RandflakeException('randflake: sequence provider must return a positive integer');
+ }
+
+ private static function assertGenerationTime(
+ int $now,
+ int $leaseStart,
+ int $leaseEnd,
+ RandflakeFormat $format,
+ ?int $leaseEndExclusive,
+ ): void {
+ if (!self::leaseContains($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive)) {
+ throw new RandflakeException('randflake: invalid lease, lease expired or not started yet');
}
- $sequence = $sequenceValue - 1;
- if ($sequence > self::MAX_SEQUENCE) {
- throw new RandflakeException(
- "randflake: resource exhausted (generator can't handle current throughput, try using multiple randflake instances)",
+ if ($now < self::EPOCH_OFFSET || $now > self::MAX_TIMESTAMP) {
+ throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime');
+ }
+ }
+
+ private static function encodeGeneratedPayload(
+ int $timestamp,
+ int $nodeId,
+ int $sequence,
+ #[\SensitiveParameter]
+ string $secret,
+ RandflakeFormat $format,
+ ): string {
+ $plain = self::packPayload($timestamp, $nodeId, $sequence);
+ if ($format === RandflakeFormat::UPSTREAM) {
+ return DecimalBytes::fromLittleEndianSigned64(
+ self::sparx($secret)->encrypt(strrev($plain)),
);
}
- $providerState[$nodeId] = $now;
+ return DecimalBytes::fromBytes(self::permute($plain, $secret, false));
+ }
- $plain = self::packPayload($now, $nodeId, $sequence);
- $cipher = self::permute($plain, $secret, false);
+ /**
+ * @throws RandflakeException|FileLockException
+ */
+ private static function generateInternal(
+ int $nodeId,
+ int $leaseStart,
+ int $leaseEnd,
+ #[\SensitiveParameter]
+ string $secret,
+ ?SequenceProviderInterface $sequenceProvider,
+ ?GenerationContext $runtime,
+ RandflakeFormat $format,
+ ?int $leaseEndExclusive,
+ ): string {
+ self::validateNode($nodeId);
+ self::validateLeaseWindow($leaseStart, $leaseEnd, $format, $leaseEndExclusive);
+ $secret = self::validateSecret($secret);
+
+ $provider = self::resolveSequenceProvider($sequenceProvider);
+ $domainKey = $format->value . ':' . $nodeId;
+ $state = self::providerState($provider, $domainKey);
+ $now = self::nowSeconds($runtime);
+ self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive);
+
+ $last = $state[$domainKey] ?? null;
+ if ($last !== null && $now < $last['timestamp']) {
+ throw new RandflakeException(
+ 'randflake: timestamp consistency violation, the current time is less than the last time',
+ );
+ }
+
+ [$now, $allocation] = self::allocateSequence(
+ $provider,
+ $nodeId,
+ $now,
+ $leaseStart,
+ $leaseEnd,
+ $format,
+ $leaseEndExclusive,
+ $runtime,
+ );
+ // A callback provider may suspend while another request advances this domain.
+ $sequence = self::normalizeAllocation($allocation, $state[$domainKey] ?? null, $now);
+ $state[$domainKey] = ['timestamp' => $now, 'sequence' => $sequence];
- return DecimalBytes::fromBytes($cipher);
+ return self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format);
}
/**
* @return array{0:int,1:int,2:int}
* @throws RandflakeException
*/
- private static function inspectBytes(string $cipherBytes, string $secret): array
- {
- $plain = self::permute($cipherBytes, $secret, true);
+ private static function inspectBytes(
+ string $cipherBytes,
+ string $secret,
+ RandflakeFormat $format,
+ ): array {
+ $plain = $format === RandflakeFormat::UPSTREAM
+ ? strrev(self::sparx($secret)->decrypt($cipherBytes))
+ : self::permute($cipherBytes, $secret, true);
[$timestamp, $nodeId, $sequence] = self::unpackPayload($plain);
if (
@@ -314,6 +499,50 @@ private static function inspectBytes(string $cipherBytes, string $secret): array
return [$timestamp, $nodeId, $sequence];
}
+ private static function leaseContains(
+ int $timestamp,
+ int $leaseStart,
+ int $leaseEnd,
+ RandflakeFormat $format,
+ ?int $leaseEndExclusive,
+ ): bool {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ return $timestamp >= $leaseStart && $timestamp < (int) $leaseEndExclusive;
+ }
+
+ return $timestamp >= $leaseStart && $timestamp <= $leaseEnd;
+ }
+
+ /**
+ * @param array{timestamp:int,sequence:int}|null $last
+ */
+ private static function normalizeAllocation(int $allocation, ?array $last, int $now): int
+ {
+ if ($allocation < 1) {
+ throw new RandflakeException('randflake: sequence provider must return a positive integer');
+ }
+
+ $sequence = $allocation - 1;
+ if ($last !== null && $now < $last['timestamp']) {
+ throw new RandflakeException('randflake: sequence allocation timestamp regressed for the active provider domain');
+ }
+ if ($last !== null && $last['timestamp'] === $now && $sequence <= $last['sequence']) {
+ throw new RandflakeException('randflake: sequence allocation regressed for the active provider domain');
+ }
+ if ($sequence > self::MAX_SEQUENCE) {
+ throw new RandflakeException(
+ "randflake: resource exhausted (generator can't handle current throughput, try using multiple randflake instances)",
+ );
+ }
+
+ return $sequence;
+ }
+
+ private static function nowSeconds(?GenerationContext $runtime): int
+ {
+ return $runtime?->nowSeconds() ?? time();
+ }
+
private static function packPayload(int $timestamp, int $nodeId, int $sequence): string
{
$timestampPart = $timestamp - self::EPOCH_OFFSET;
@@ -353,9 +582,33 @@ private static function permute(string $block, string $secret, bool $decrypt): s
return pack('N2', $left, $right);
}
+ /**
+ * @return \ArrayObject
+ */
+ private static function providerState(
+ SequenceProviderInterface $provider,
+ string $domainKey,
+ ): \ArrayObject {
+ self::$lastTimestampByProvider ??= new \WeakMap();
+
+ /** @var \ArrayObject|null $state */
+ $state = self::$lastTimestampByProvider[$provider] ?? null;
+ if ($state === null) {
+ /** @var \ArrayObject $state */
+ $state = new \ArrayObject();
+ self::$lastTimestampByProvider[$provider] = $state;
+ }
+
+ if (!isset($state[$domainKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) {
+ throw new RandflakeException('randflake: provider domain limit exceeded');
+ }
+
+ return $state;
+ }
+
private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface
{
- return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider();
+ return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64);
}
private static function roundFunction(int $value, int $key): int
@@ -395,6 +648,22 @@ private static function roundKeys(string $secret): array
return self::$roundKeyCache[$fingerprint] = $keys;
}
+ private static function sparx(
+ #[\SensitiveParameter]
+ string $secret,
+ ): Sparx64 {
+ $fingerprint = hash('sha256', $secret);
+ if (isset(self::$sparxCache[$fingerprint])) {
+ return self::$sparxCache[$fingerprint];
+ }
+
+ if (count(self::$sparxCache) === 16) {
+ array_shift(self::$sparxCache);
+ }
+
+ return self::$sparxCache[$fingerprint] = new Sparx64($secret);
+ }
+
/**
* @param array|false $parts
* @throws RandflakeException
@@ -432,8 +701,25 @@ private static function unpackPayload(string $payload): array
/**
* @throws RandflakeException
*/
- private static function validateLeaseWindow(int $leaseStart, int $leaseEnd): void
- {
+ private static function validateLeaseWindow(
+ int $leaseStart,
+ int $leaseEnd,
+ RandflakeFormat $format,
+ ?int $leaseEndExclusive,
+ ): void {
+ if ($format === RandflakeFormat::UPSTREAM) {
+ if (
+ $leaseEndExclusive === null
+ || $leaseStart < self::EPOCH_OFFSET
+ || $leaseEndExclusive <= $leaseStart
+ || $leaseEndExclusive > self::MAX_TIMESTAMP + 1
+ ) {
+ throw new RandflakeException('randflake: invalid lease, lease expired or not started yet');
+ }
+
+ return;
+ }
+
if ($leaseStart > $leaseEnd || $leaseEnd > self::MAX_TIMESTAMP) {
throw new RandflakeException('randflake: invalid lease, lease expired or not started yet');
}
@@ -452,8 +738,10 @@ private static function validateNode(int $nodeId): void
/**
* @throws RandflakeException
*/
- private static function validateSecret(string $secret): string
- {
+ private static function validateSecret(
+ #[\SensitiveParameter]
+ string $secret,
+ ): string {
if (strlen($secret) !== 16) {
throw new RandflakeException('randflake: invalid secret, secret must be 16 bytes long');
}
diff --git a/src/RandomId.php b/src/RandomId.php
index 799cf1d..afe0dc9 100644
--- a/src/RandomId.php
+++ b/src/RandomId.php
@@ -8,7 +8,7 @@
final class RandomId
{
- public const DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ public const string DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
public static function generate(int $length = 21, string $alphabet = self::DEFAULT_ALPHABET): string
{
diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php
new file mode 100644
index 0000000..4b23912
--- /dev/null
+++ b/src/Runtime/GenerationContext.php
@@ -0,0 +1,78 @@
+runwire?->assertActive();
+ }
+
+ public function nowMicroseconds(): int
+ {
+ $this->assertActive();
+
+ if ($this->clock === null) {
+ return (int) floor(microtime(true) * 1_000_000);
+ }
+
+ $now = $this->clock->now();
+ $seconds = $now->getTimestamp();
+ if ($seconds < 0 || $seconds > intdiv(PHP_INT_MAX - 999_999, 1_000_000)) {
+ throw new InvalidArgumentException('Generation clock must fit in non-negative integer microseconds');
+ }
+
+ return ($seconds * 1_000_000) + (int) $now->format('u');
+ }
+
+ public function nowMilliseconds(): int
+ {
+ return intdiv($this->nowMicroseconds(), 1_000);
+ }
+
+ public function nowSeconds(): int
+ {
+ return intdiv($this->nowMicroseconds(), 1_000_000);
+ }
+
+ public function sleepMicroseconds(int $microseconds): void
+ {
+ $this->assertActive();
+ if ($this->runwire !== null) {
+ $this->runwire->sleep($microseconds / 1_000_000);
+ $this->assertActive();
+
+ return;
+ }
+
+ usleep($microseconds);
+ }
+
+ public function waitDeadlineNanoseconds(): int
+ {
+ $now = hrtime(true);
+ $timeout = min($this->waitTimeoutMicros, intdiv(PHP_INT_MAX - $now, 1_000));
+ $deadline = $now + ($timeout * 1_000);
+ $runwireDeadline = $this->runwire?->deadlineNanoseconds();
+
+ return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline);
+ }
+}
diff --git a/src/Runtime/RunwireBinding.php b/src/Runtime/RunwireBinding.php
new file mode 100644
index 0000000..810eb9d
--- /dev/null
+++ b/src/Runtime/RunwireBinding.php
@@ -0,0 +1,83 @@
+assertActive();
+ }
+
+ public function assertActive(): void
+ {
+ $pid = getmypid();
+ if (!is_int($pid) || $pid !== $this->runtime->pid) {
+ throw new LogicException('Runwire binding belongs to a different process.');
+ }
+
+ if ($this->request !== null) {
+ if ($this->request->runtime() !== $this->runtime) {
+ throw new LogicException('Runwire request belongs to a different runtime context.');
+ }
+ if ($this->request->completed()) {
+ throw new LogicException('Runwire request has already completed.');
+ }
+
+ $this->request->cancellation->throwIfCancelled();
+ }
+
+ if ($this->scope !== null) {
+ if (!$this->runtime->supports(RuntimeCapability::RUNWIRE_COROUTINES)) {
+ throw new LogicException('Runwire coroutine scope requires coroutine capability.');
+ }
+
+ self::$scopeProbe ??= new TaskLocal();
+ $this->scope->hasLocal(self::$scopeProbe);
+ $this->scope->cancellation()->throwIfCancelled();
+ }
+ }
+
+ public function deadlineNanoseconds(): ?int
+ {
+ $this->assertActive();
+ $requestDeadline = $this->request?->deadline()->monotonicNanoseconds;
+ $scopeDeadline = $this->scope?->cancellation()->deadline()->monotonicNanoseconds;
+
+ if ($requestDeadline === null) {
+ return $scopeDeadline;
+ }
+ if ($scopeDeadline === null) {
+ return $requestDeadline;
+ }
+
+ return min($requestDeadline, $scopeDeadline);
+ }
+
+ public function sleep(float $seconds): void
+ {
+ $this->assertActive();
+
+ if ($this->scope !== null) {
+ $this->scope->sleep($seconds);
+ } else {
+ usleep((int) ceil($seconds * 1_000_000));
+ }
+
+ $this->assertActive();
+ }
+}
diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php
index 6009a9d..741c711 100644
--- a/src/Sequence/FilesystemSequenceProvider.php
+++ b/src/Sequence/FilesystemSequenceProvider.php
@@ -6,14 +6,17 @@
use Infocyph\UID\Exceptions\FileLockException;
use Infocyph\UID\Exceptions\SequenceTimestampException;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Support\FileLock;
use InvalidArgumentException;
final class FilesystemSequenceProvider implements SequenceProviderInterface
{
- private const MAX_PATH_CACHE = 1024;
+ private const int MAX_PATH_CACHE = 1024;
- private const MAX_SEQUENCE_STATE_BYTES = 64;
+ private const int MAX_RESERVATIONS = 1024;
+
+ private const int MAX_SEQUENCE_STATE_BYTES = 64;
private readonly string $baseDirectory;
@@ -30,6 +33,7 @@ public function __construct(
private readonly string $namespace = '',
private readonly ?int $lockTimeoutMicros = null,
private readonly int $reservationSize = 1,
+ private readonly ?GenerationContext $runtime = null,
) {
$this->baseDirectory = $baseDirectory ?: sys_get_temp_dir();
@@ -46,21 +50,18 @@ public function __construct(
}
}
- public function next(string $type, int $machineId, int $timestamp): int
+ public function next(string $type, int $machineId, int $timestamp, ?GenerationContext $runtime = null): int
{
+ $this->runtime?->assertActive();
+ $runtime ??= $this->runtime;
+ $runtime?->assertActive();
$fileLocation = $this->sequenceFileLocation($type, $machineId);
- $this->resetAfterFork();
- $reservation = $this->reservations[$fileLocation] ?? null;
-
- if (
- $reservation !== null
- && $reservation['timestamp'] === $timestamp
- && $reservation['next'] <= $reservation['end']
- ) {
- $allocation = $reservation['next'];
- $this->reservations[$fileLocation]['next'] = $allocation + 1;
-
- return $allocation;
+ if ($this->reservationSize > 1) {
+ $this->resetAfterFork();
+ $reserved = $this->takeReservedAllocation($fileLocation, $timestamp);
+ if ($reserved !== null) {
+ return $reserved;
+ }
}
$handle = FileLock::acquire(
@@ -68,6 +69,7 @@ public function next(string $type, int $machineId, int $timestamp): int
$this->lockTimeoutMicros,
'Failed to open sequence file: ' . $fileLocation,
'Unable to acquire sequence lock: ' . $fileLocation,
+ $runtime,
);
try {
@@ -75,20 +77,22 @@ public function next(string $type, int $machineId, int $timestamp): int
if ($lastTimestamp > $timestamp) {
throw new SequenceTimestampException($lastTimestamp, $timestamp);
}
+ if ($lastTimestamp === $timestamp && $lastAllocation === PHP_INT_MAX) {
+ throw new FileLockException('Sequence value exhausted');
+ }
$allocation = $lastTimestamp === $timestamp ? $lastAllocation + 1 : 1;
- if ($allocation > PHP_INT_MAX - $this->reservationSize + 1) {
+ $reservationOffset = $this->reservationSize - 1;
+ if ($allocation > PHP_INT_MAX - $reservationOffset) {
throw new FileLockException('Sequence value exhausted');
}
- $reservedEnd = $allocation + $this->reservationSize - 1;
- $state = $timestamp . ',' . $reservedEnd;
- $this->writeState($handle, $state, $oldLength);
- $this->reservations[$fileLocation] = [
- 'timestamp' => $timestamp,
- 'next' => $allocation + 1,
- 'end' => $reservedEnd,
- ];
+ $reservedEnd = $allocation + $reservationOffset;
+ $runtime?->assertActive();
+ $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength);
+ if ($this->reservationSize > 1) {
+ $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd);
+ }
return $allocation;
} finally {
@@ -97,13 +101,6 @@ public function next(string $type, int $machineId, int $timestamp): int
}
}
- private static function isCanonicalInteger(string $value): bool
- {
- return $value !== ''
- && ctype_digit($value)
- && ($value === '0' || $value[0] !== '0');
- }
-
/**
* @param resource $handle
* @return array{0:int,1:int,2:int}
@@ -124,22 +121,15 @@ private function readState($handle): array
return [0, 0, 0];
}
- $comma = strpos($state, ',');
- if ($comma === false || str_contains(substr($state, $comma + 1), ',')) {
- throw new FileLockException('Sequence state is malformed');
- }
-
- $timestamp = substr($state, 0, $comma);
- $allocation = substr($state, $comma + 1);
- if (!self::isCanonicalInteger($timestamp) || !self::isCanonicalInteger($allocation)) {
+ if (preg_match('/\A(0|[1-9][0-9]{0,18}),(0|[1-9][0-9]{0,18})\z/', $state, $parts) !== 1) {
throw new FileLockException('Sequence state is malformed');
}
+ $timestamp = $parts[1];
+ $allocation = $parts[2];
if (
- strlen($timestamp) > 19
- || strlen($allocation) > 19
- || (strlen($timestamp) === 19 && $timestamp > (string) PHP_INT_MAX)
- || (strlen($allocation) === 19 && $allocation > (string) PHP_INT_MAX)
+ (strlen($timestamp) === 19 && strcmp($timestamp, (string) PHP_INT_MAX) > 0)
+ || (strlen($allocation) === 19 && strcmp($allocation, (string) PHP_INT_MAX) > 0)
) {
throw new FileLockException('Sequence state is malformed');
}
@@ -177,6 +167,44 @@ private function sequenceFileLocation(string $type, int $machineId): string
return $this->pathCache[$cacheKey] = $this->baseDirectory . DIRECTORY_SEPARATOR . $name;
}
+ private function storeReservation(
+ string $fileLocation,
+ int $timestamp,
+ int $allocation,
+ int $reservedEnd,
+ ): void {
+ if (!isset($this->reservations[$fileLocation]) && count($this->reservations) >= self::MAX_RESERVATIONS) {
+ throw new FileLockException('Sequence reservation domain limit exceeded');
+ }
+
+ $this->reservations[$fileLocation] = [
+ 'timestamp' => $timestamp,
+ 'next' => $allocation + 1,
+ 'end' => $reservedEnd,
+ ];
+ }
+
+ private function takeReservedAllocation(string $fileLocation, int $timestamp): ?int
+ {
+ $reservation = $this->reservations[$fileLocation] ?? null;
+ if (
+ $reservation === null
+ || $reservation['timestamp'] !== $timestamp
+ || $reservation['next'] > $reservation['end']
+ ) {
+ return null;
+ }
+
+ $allocation = $reservation['next'];
+ if ($allocation === $reservation['end']) {
+ unset($this->reservations[$fileLocation]);
+ } else {
+ $this->reservations[$fileLocation]['next'] = $allocation + 1;
+ }
+
+ return $allocation;
+ }
+
/**
* @param resource $handle
*/
diff --git a/src/Sequence/InMemorySequenceProvider.php b/src/Sequence/InMemorySequenceProvider.php
index 5f0265e..26b6009 100644
--- a/src/Sequence/InMemorySequenceProvider.php
+++ b/src/Sequence/InMemorySequenceProvider.php
@@ -9,6 +9,8 @@
final class InMemorySequenceProvider implements SequenceProviderInterface
{
+ private const int MAX_DOMAINS = 1024;
+
/**
* @var array
*/
@@ -17,6 +19,10 @@ final class InMemorySequenceProvider implements SequenceProviderInterface
public function next(string $type, int $machineId, int $timestamp): int
{
$key = $this->key($type, $machineId);
+ if (!isset($this->state[$key]) && count($this->state) >= self::MAX_DOMAINS) {
+ throw new FileLockException('In-memory sequence domain limit exceeded');
+ }
+
$last = $this->state[$key] ?? null;
$sequence = 1;
diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php
index 037041f..918e6ec 100644
--- a/src/Sequence/PsrSimpleCacheSequenceProvider.php
+++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php
@@ -7,28 +7,42 @@
use Closure;
use Infocyph\UID\Exceptions\FileLockException;
use Infocyph\UID\Exceptions\SequenceTimestampException;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Support\FileLock;
use InvalidArgumentException;
use Psr\SimpleCache\CacheInterface;
use Throwable;
-final readonly class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface
+final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface
{
- private ?Closure $synchronizer;
+ private const int MAX_OBSERVED_DOMAINS = 1024;
+
+ private readonly ?Closure $synchronizer;
+
+ /** @var array */
+ private array $observedState = [];
/**
* @param callable(string, callable():int):mixed|null $synchronizer
*/
public function __construct(
- private CacheInterface $cache,
- private string $prefix = 'uid.seq.',
- private int $waitTime = 1_000,
- private int $maxAttempts = 1_000,
+ private readonly CacheInterface $cache,
+ private readonly string $prefix = 'uid.seq.',
+ private readonly int $waitTime = 1_000,
+ private readonly int $maxAttempts = 1_000,
?callable $synchronizer = null,
+ private readonly ?GenerationContext $runtime = null,
) {
if (preg_match('/^[A-Za-z0-9_.]*$/D', $this->prefix) !== 1) {
throw new InvalidArgumentException('Cache key prefix contains characters not guaranteed by PSR-16');
}
+ if (
+ $waitTime < 1
+ || $maxAttempts < 1
+ || $waitTime > intdiv(PHP_INT_MAX, $maxAttempts)
+ ) {
+ throw new InvalidArgumentException('Cache sequence wait policy must contain positive bounded values');
+ }
$this->synchronizer = $synchronizer ? $synchronizer(...) : null;
}
@@ -36,56 +50,77 @@ public function __construct(
/**
* @throws FileLockException
*/
- public function next(string $type, int $machineId, int $timestamp): int
+ public function next(string $type, int $machineId, int $timestamp, ?GenerationContext $runtime = null): int
{
+ $this->runtime?->assertActive();
+ $runtime ??= $this->runtime;
+ $runtime?->assertActive();
$key = $this->key($type, $machineId);
+ if (!isset($this->observedState[$key]) && count($this->observedState) >= self::MAX_OBSERVED_DOMAINS) {
+ throw new FileLockException('Observed PSR-16 sequence domain limit exceeded');
+ }
if ($this->synchronizer !== null) {
- try {
- $sequence = ($this->synchronizer)(
- $key,
- fn(): int => $this->nextFromCacheState($key, $timestamp),
- );
- } catch (FileLockException $exception) {
- throw $exception;
- } catch (Throwable $exception) {
- throw new FileLockException(
- 'Failed to read/write sequence state from PSR cache for key: ' . $key,
- 0,
- $exception,
- );
- }
-
- if (!is_int($sequence) || $sequence < 1) {
- throw new FileLockException('Sequence synchronizer must return a positive integer');
- }
-
- return $sequence;
- }
-
- $lock = $this->acquireLock($key);
+ return $this->nextSynchronized($this->synchronizer, $key, $timestamp, $runtime);
+ }
+
+ $lock = $this->acquireLock($key, $runtime);
try {
- return $this->nextFromCacheState($key, $timestamp);
- } catch (FileLockException $exception) {
- throw $exception;
- } catch (Throwable $exception) {
- throw new FileLockException(
- 'Failed to read/write sequence state from PSR cache for key: ' . $key,
- 0,
- $exception,
- );
+ return $this->nextSafely($key, $timestamp, $runtime);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
+ /**
+ * @param array{timestamp:int,sequence:int}|null $state
+ * @param array{timestamp:int,sequence:int}|null $observed
+ */
+ private static function assertNotRegressed(?array $state, ?array $observed, string $key): void
+ {
+ if ($state === null || $observed === null) {
+ return;
+ }
+ if ($state['timestamp'] < $observed['timestamp']) {
+ throw new FileLockException('Cached sequence state regressed for key: ' . $key);
+ }
+ if ($state['timestamp'] === $observed['timestamp'] && $state['sequence'] < $observed['sequence']) {
+ throw new FileLockException('Cached sequence state regressed for key: ' . $key);
+ }
+ }
+
+ /**
+ * @param array{timestamp:int,sequence:int}|null $state
+ */
+ private static function nextSequence(?array $state, int $timestamp, string $key): int
+ {
+ if ($state === null) {
+ return 1;
+ }
+ if ($state['timestamp'] > $timestamp) {
+ throw new SequenceTimestampException(
+ $state['timestamp'],
+ $timestamp,
+ 'Sequence timestamp moved backwards for key: ' . $key,
+ );
+ }
+ if ($state['timestamp'] !== $timestamp) {
+ return 1;
+ }
+ if ($state['sequence'] === PHP_INT_MAX) {
+ throw new FileLockException('Sequence value exhausted for key: ' . $key);
+ }
+
+ return $state['sequence'] + 1;
+ }
+
/**
* @return resource
* @throws FileLockException
*/
- private function acquireLock(string $key)
+ private function acquireLock(string $key, ?GenerationContext $runtime)
{
$lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-cache-lock-' . hash('sha256', $key) . '.lck';
@@ -94,6 +129,7 @@ private function acquireLock(string $key)
$this->waitTime * $this->maxAttempts,
'Unable to open sequence cache lock file: ' . $lockFile,
'Unable to acquire sequence cache lock for key: ' . $key,
+ $runtime,
);
}
@@ -111,43 +147,95 @@ private function key(string $type, int $machineId): string
return $key;
}
- private function nextFromCacheState(string $key, int $timestamp): int
+ private function nextFromCacheState(string $key, int $timestamp, ?GenerationContext $runtime): int
{
- $state = $this->cache->get($key);
- $sequence = 1;
- if ($state !== null) {
- if (
- !is_array($state)
- || !isset($state['timestamp'], $state['sequence'])
- || !is_int($state['timestamp'])
- || !is_int($state['sequence'])
- || $state['timestamp'] < 0
- || $state['sequence'] < 1
- ) {
- throw new FileLockException('Cached sequence state is malformed for key: ' . $key);
- }
-
- if ($state['timestamp'] > $timestamp) {
- throw new SequenceTimestampException(
- $state['timestamp'],
- $timestamp,
- 'Sequence timestamp moved backwards for key: ' . $key,
- );
- }
-
- if ($state['timestamp'] === $timestamp) {
- if ($state['sequence'] === PHP_INT_MAX) {
- throw new FileLockException('Sequence value exhausted for key: ' . $key);
- }
-
- $sequence = $state['sequence'] + 1;
- }
- }
-
- if (!$this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence])) {
+ $runtime?->assertActive();
+ $state = $this->normalizeState($this->cache->get($key), $key);
+ $observed = $this->observedState[$key] ?? null;
+ if ($state === null && $observed !== null) {
+ throw new FileLockException('Cached sequence state was lost for key: ' . $key);
+ }
+
+ self::assertNotRegressed($state, $observed, $key);
+ $sequence = self::nextSequence($state, $timestamp, $key);
+ $nextState = ['timestamp' => $timestamp, 'sequence' => $sequence];
+ $runtime?->assertActive();
+ if (!$this->cache->set($key, $nextState)) {
throw new FileLockException('Failed to persist sequence state for key: ' . $key);
}
+ $this->observedState[$key] = $nextState;
+
return $sequence;
}
+
+ private function nextSafely(string $key, int $timestamp, ?GenerationContext $runtime): int
+ {
+ try {
+ return $this->nextFromCacheState($key, $timestamp, $runtime);
+ } catch (FileLockException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ $runtime?->assertActive();
+
+ throw $this->storageFailure($key, $exception);
+ }
+ }
+
+ private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp, ?GenerationContext $runtime): int
+ {
+ try {
+ $sequence = $synchronizer(
+ $key,
+ fn(): int => $this->nextFromCacheState($key, $timestamp, $runtime),
+ );
+ } catch (FileLockException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ $runtime?->assertActive();
+
+ throw $this->storageFailure($key, $exception);
+ }
+
+ if (!is_int($sequence) || $sequence < 1) {
+ throw new FileLockException('Sequence synchronizer must return a positive integer');
+ }
+
+ return $sequence;
+ }
+
+ /**
+ * @return array{timestamp:int,sequence:int}|null
+ */
+ private function normalizeState(mixed $state, string $key): ?array
+ {
+ if ($state === null) {
+ return null;
+ }
+ if (!is_array($state)) {
+ throw new FileLockException('Cached sequence state is malformed for key: ' . $key);
+ }
+
+ $stateTimestamp = $state['timestamp'] ?? null;
+ $stateSequence = $state['sequence'] ?? null;
+ if (
+ !is_int($stateTimestamp)
+ || !is_int($stateSequence)
+ || $stateTimestamp < 0
+ || $stateSequence < 1
+ ) {
+ throw new FileLockException('Cached sequence state is malformed for key: ' . $key);
+ }
+
+ return ['timestamp' => $stateTimestamp, 'sequence' => $stateSequence];
+ }
+
+ private function storageFailure(string $key, Throwable $exception): FileLockException
+ {
+ return new FileLockException(
+ 'Failed to read/write sequence state from PSR cache for key: ' . $key,
+ 0,
+ $exception,
+ );
+ }
}
diff --git a/src/Snowflake.php b/src/Snowflake.php
index 1f46468..0a17b0d 100644
--- a/src/Snowflake.php
+++ b/src/Snowflake.php
@@ -11,6 +11,7 @@
use Infocyph\UID\Exceptions\FileLockException;
use Infocyph\UID\Exceptions\SequenceTimestampException;
use Infocyph\UID\Exceptions\SnowflakeException;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\FilesystemSequenceProvider;
use Infocyph\UID\Sequence\SequenceProviderInterface;
use Infocyph\UID\Support\BaseEncoder;
@@ -22,15 +23,19 @@ final class Snowflake
{
use GetSequence;
- private const DATACENTER_BITS = 5;
+ private const int DATACENTER_BITS = 5;
- private const DEFAULT_EPOCH = 1_577_836_800_000;
+ private const int DEFAULT_EPOCH = 1_577_836_800_000;
- private const SEQUENCE_BITS = 12;
+ private const int MAX_PROVIDER_DOMAINS = 1024;
- private const TIMESTAMP_BITS = 41;
+ private const int SEQUENCE_BITS = 12;
- private const WORKER_BITS = 5;
+ private const int TIMESTAMP_BITS = 41;
+
+ private const int WAIT_TIMEOUT_MICROS = 1_000_000;
+
+ private const int WORKER_BITS = 5;
/** @var \WeakMap>|null */
private static ?\WeakMap $lastStateByProvider = null;
@@ -68,7 +73,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string
return self::generateInternal(
$datacenter,
$workerId,
- self::getStartTimeStamp(),
+ self::DEFAULT_EPOCH,
ClockBackwardPolicy::WAIT,
);
}
@@ -81,14 +86,15 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string
public static function generateWithConfig(SnowflakeConfig $config): string
{
[$datacenterId, $workerId] = $config->resolveNode();
- $customEpoch = $config->resolveCustomEpochMs();
+ $customEpoch = $config->customEpoch;
return self::generateInternal(
$datacenterId,
$workerId,
- $customEpoch ?? self::getStartTimeStamp(),
+ $customEpoch ?? self::DEFAULT_EPOCH,
$config->clockBackwardPolicy,
$config->sequenceProvider,
+ $config->runtime,
);
}
@@ -113,7 +119,7 @@ public static function parse(string $id): array
{
return self::parseWithEpoch(
id: $id,
- startTimestamp: self::getStartTimeStamp(),
+ startTimestamp: self::DEFAULT_EPOCH,
);
}
@@ -166,6 +172,15 @@ public static function toBytes(string $id): string
return self::encodeNumericBytes($id);
}
+ private static function assertDecodedId(string $id): string
+ {
+ if (!self::isValid($id)) {
+ throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain');
+ }
+
+ return $id;
+ }
+
/**
* @throws SnowflakeException
*/
@@ -201,22 +216,26 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta
private static function decodeNumericBase(string $encoded, int $base): string
{
- return NumericConversion::decimalFromBase(
+ $id = NumericConversion::decimalFromBase(
$encoded,
$base,
8,
static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception),
);
+
+ return self::assertDecodedId($id);
}
private static function decodeNumericBytes(string $bytes): string
{
- return NumericConversion::decimalFromBytes(
+ $id = NumericConversion::decimalFromBytes(
$bytes,
8,
'Snowflake binary data must be exactly 8 bytes',
static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception),
);
+
+ return self::assertDecodedId($id);
}
private static function encodeNumericBytes(string $id): string
@@ -240,22 +259,22 @@ private static function generateInternal(
int $startTimestamp,
ClockBackwardPolicy $clockBackwardPolicy,
?SequenceProviderInterface $sequenceProvider = null,
+ ?GenerationContext $runtime = null,
): string {
self::assertNodeIds($datacenter, $workerId);
- $currentTime = (int) floor(microtime(true) * 1000);
- self::assertTimestampRange($currentTime, $startTimestamp);
-
$resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider);
$sequenceKey = ($datacenter << self::WORKER_BITS) | $workerId;
$stateKey = $startTimestamp . ':' . $sequenceKey;
- self::$lastStateByProvider ??= new \WeakMap();
- $providerState = self::$lastStateByProvider[$resolvedSequenceProvider] ??= new \ArrayObject();
+ $providerState = self::providerState($resolvedSequenceProvider, $stateKey);
$maxSequence = -1 ^ (-1 << self::SEQUENCE_BITS);
$sequenceType = $startTimestamp === self::DEFAULT_EPOCH
? 'snowflake'
: 'snowflake_' . $startTimestamp;
+ $currentTime = self::nowMilliseconds($runtime);
+ self::assertTimestampRange($currentTime, $startTimestamp);
+
while (true) {
[$currentTime, $sequence] = self::nextSequenceAtValidTimestamp(
$currentTime,
@@ -265,6 +284,7 @@ private static function generateInternal(
$clockBackwardPolicy,
$resolvedSequenceProvider,
$sequenceType,
+ $runtime,
);
$lastState = $providerState[$stateKey] ?? null;
@@ -279,7 +299,7 @@ private static function generateInternal(
$currentTime < $lastState['timestamp']
|| ($currentTime === $lastState['timestamp'] && $sequence <= $lastState['sequence'])
) {
- $currentTime = self::waitUntil($lastState['timestamp'] + 1);
+ $currentTime = self::waitUntil($lastState['timestamp'] + 1, $runtime);
self::assertTimestampRange($currentTime, $startTimestamp);
continue;
@@ -303,14 +323,6 @@ private static function generateInternal(
| ($sequence));
}
- /**
- * Retrieves the start timestamp.
- */
- private static function getStartTimeStamp(): int
- {
- return self::DEFAULT_EPOCH;
- }
-
/**
* @return array{0:int, 1:int}
* @throws FileLockException|SnowflakeException
@@ -323,10 +335,11 @@ private static function nextSequenceAtValidTimestamp(
ClockBackwardPolicy $clockBackwardPolicy,
SequenceProviderInterface $sequenceProvider,
string $sequenceType,
+ ?GenerationContext $runtime,
): array {
while (true) {
try {
- $allocation = self::sequence($currentTime, $sequenceKey, $sequenceType, $sequenceProvider);
+ $allocation = self::sequence($currentTime, $sequenceKey, $sequenceType, $sequenceProvider, $runtime);
} catch (SequenceTimestampException $exception) {
if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) {
throw new SnowflakeException(
@@ -336,7 +349,7 @@ private static function nextSequenceAtValidTimestamp(
);
}
- $currentTime = self::waitUntil($exception->lastTimestamp);
+ $currentTime = self::waitUntil($exception->lastTimestamp, $runtime);
self::assertTimestampRange($currentTime, $startTimestamp);
continue;
@@ -350,11 +363,42 @@ private static function nextSequenceAtValidTimestamp(
return [$currentTime, $allocation - 1];
}
- $currentTime = self::waitUntil($currentTime + 1);
+ $currentTime = self::waitUntil($currentTime + 1, $runtime);
self::assertTimestampRange($currentTime, $startTimestamp);
}
}
+ private static function nowMilliseconds(?GenerationContext $runtime): int
+ {
+ return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000);
+ }
+
+ /**
+ * @return \ArrayObject
+ */
+ private static function providerState(
+ SequenceProviderInterface $provider,
+ string $stateKey,
+ ): \ArrayObject {
+ self::$lastStateByProvider ??= new \WeakMap();
+
+ /** @var \ArrayObject|null $state */
+ $state = self::$lastStateByProvider[$provider] ?? null;
+ if ($state === null) {
+ /** @var \ArrayObject $state */
+ $state = new \ArrayObject();
+ self::$lastStateByProvider[$provider] = $state;
+
+ return $state;
+ }
+
+ if (!isset($state[$stateKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) {
+ throw new SnowflakeException('Snowflake provider domain limit exceeded');
+ }
+
+ return $state;
+ }
+
private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface
{
return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider();
@@ -368,12 +412,21 @@ private static function timestampParts(int $timestamp): array
return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)];
}
- private static function waitUntil(int $timestamp): int
+ private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int
{
- $now = (int) floor(microtime(true) * 1000);
- while ($now < $timestamp) {
- usleep(1000);
- $now = (int) floor(microtime(true) * 1000);
+ $deadline = $runtime?->waitDeadlineNanoseconds()
+ ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000);
+
+ while (($now = self::nowMilliseconds($runtime)) < $timestamp) {
+ if (hrtime(true) >= $deadline) {
+ throw new SnowflakeException('Timed out waiting for a valid Snowflake timestamp');
+ }
+
+ if ($runtime !== null) {
+ $runtime->sleepMicroseconds(1_000);
+ } else {
+ usleep(1_000);
+ }
}
return $now;
diff --git a/src/Sonyflake.php b/src/Sonyflake.php
index 4b8b002..b269cef 100644
--- a/src/Sonyflake.php
+++ b/src/Sonyflake.php
@@ -8,9 +8,11 @@
use Exception;
use Infocyph\UID\Configuration\SonyflakeConfig;
use Infocyph\UID\Enums\ClockBackwardPolicy;
+use Infocyph\UID\Enums\SonyflakeFormat;
use Infocyph\UID\Exceptions\FileLockException;
use Infocyph\UID\Exceptions\SequenceTimestampException;
use Infocyph\UID\Exceptions\SonyflakeException;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\FilesystemSequenceProvider;
use Infocyph\UID\Sequence\SequenceProviderInterface;
use Infocyph\UID\Support\BaseEncoder;
@@ -22,16 +24,22 @@ final class Sonyflake
{
use GetSequence;
- private const DEFAULT_EPOCH = 1_577_836_800_000;
+ private const int DEFAULT_EPOCH = 1_577_836_800_000;
- private const MACHINE_BITS = 16;
+ private const int MACHINE_BITS = 16;
- private const SEQUENCE_BITS = 8;
+ private const int MAX_PROVIDER_DOMAINS = 1024;
- private const TIMESTAMP_BITS = 39;
+ private const int SEQUENCE_BITS = 8;
- /** @var array */
- private static array $lastWallTimeByDomain = [];
+ private const int TIMESTAMP_BITS = 39;
+
+ private const int UPSTREAM_DEFAULT_EPOCH = 1_409_529_600_000;
+
+ private const int WAIT_TIMEOUT_MICROS = 1_000_000;
+
+ /** @var \WeakMap>|null */
+ private static ?\WeakMap $lastWallTimeByProvider = null;
/**
* Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal.
@@ -40,10 +48,12 @@ final class Sonyflake
*/
public static function fromBase(string $encoded, int $base): string
{
- return self::decodeNumeric(
+ $id = self::decodeNumeric(
fn(): string => NumericIdCodec::decimalFromBase($encoded, $base, 8),
null,
);
+
+ return self::assertDecodedId($id);
}
/**
@@ -53,10 +63,12 @@ public static function fromBase(string $encoded, int $base): string
*/
public static function fromBytes(string $bytes): string
{
- return self::decodeNumeric(
+ $id = self::decodeNumeric(
fn(): string => NumericIdCodec::decimalFromBytes($bytes, 8),
'Sonyflake binary data must be exactly 8 bytes',
);
+
+ return self::assertDecodedId($id);
}
/**
@@ -70,8 +82,9 @@ public static function generate(int $machineId = 0): string
{
return self::generateInternal(
$machineId,
- self::getStartTimeStamp(),
+ self::getStartTimeStamp(SonyflakeFormat::UID),
ClockBackwardPolicy::WAIT,
+ format: SonyflakeFormat::UID,
);
}
@@ -84,9 +97,11 @@ public static function generateWithConfig(SonyflakeConfig $config): string
{
return self::generateInternal(
$config->resolveMachineId(),
- $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(),
+ $config->resolveCustomEpochMs() ?? self::getStartTimeStamp($config->format),
$config->clockBackwardPolicy,
$config->sequenceProvider,
+ $config->runtime,
+ $config->format,
);
}
@@ -107,9 +122,9 @@ public static function isValid(string $id): bool
* @return array{time: DateTimeImmutable, sequence: int, machine_id: int}
* @throws Exception
*/
- public static function parse(string $id): array
+ public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array
{
- return self::parseWithEpoch($id, self::getStartTimeStamp());
+ return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format);
}
/**
@@ -118,13 +133,16 @@ public static function parse(string $id): array
* @return array{time: DateTimeImmutable, sequence: int, machine_id: int}
* @throws Exception
*/
- public static function parseWithEpoch(string $id, int $startTimestamp): array
- {
+ public static function parseWithEpoch(
+ string $id,
+ int $startTimestamp,
+ SonyflakeFormat $format = SonyflakeFormat::UID,
+ ): array {
if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) {
throw new SonyflakeException('Invalid Sonyflake ID string');
}
- $parts = self::extractParts($id, $startTimestamp);
+ $parts = self::extractParts($id, $startTimestamp, $format);
return [
'time' => new DateTimeImmutable(
@@ -166,6 +184,67 @@ public static function toBytes(string $id): string
);
}
+ /**
+ * @return array{0:int,1:int}
+ */
+ private static function allocateSequence(
+ SequenceProviderInterface $provider,
+ int $machineId,
+ int $startTimestamp,
+ int $elapsedTime,
+ ClockBackwardPolicy $policy,
+ ?GenerationContext $runtime,
+ SonyflakeFormat $format,
+ ): array {
+ $sequenceType = $format === SonyflakeFormat::UID
+ ? 'sonyflake_' . $startTimestamp
+ : 'sonyflake_upstream_' . $startTimestamp;
+
+ while (true) {
+ try {
+ $allocation = self::sequence($elapsedTime, $machineId, $sequenceType, $provider, $runtime);
+ } catch (SequenceTimestampException $exception) {
+ if ($policy === ClockBackwardPolicy::THROW) {
+ throw new SonyflakeException(
+ 'Clock moved backwards while generating Sonyflake ID',
+ 0,
+ $exception,
+ );
+ }
+
+ $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp, $runtime);
+
+ continue;
+ }
+
+ if ($allocation < 1) {
+ throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation');
+ }
+ if ($allocation <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) {
+ return [$elapsedTime, $allocation - 1];
+ }
+
+ $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp, $runtime);
+ }
+ }
+
+ private static function assertDecodedId(string $id): string
+ {
+ if (!self::isValid($id)) {
+ throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain');
+ }
+
+ return $id;
+ }
+
+ private static function assertMachineId(int $machineId): void
+ {
+ $maximum = -1 ^ (-1 << self::MACHINE_BITS);
+ if ($machineId < 0 || $machineId > $maximum) {
+ throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maximum.");
+ }
+ }
+
/**
* @param callable():string $operation
* @throws SonyflakeException
@@ -184,6 +263,13 @@ private static function decodeNumeric(callable $operation, ?string $customMessag
*/
private static function elapsedTime(int $currentTime, int $startTimestamp): int
{
+ if ($currentTime < $startTimestamp) {
+ throw new SonyflakeException('Sonyflake epoch must not be in the future');
+ }
+ if ($startTimestamp < $currentTime - (((1 << self::TIMESTAMP_BITS) - 1) * 10 + 9)) {
+ throw new SonyflakeException('Exceeding the maximum life cycle of the algorithm');
+ }
+
return intdiv($currentTime - $startTimestamp, 10);
}
@@ -207,8 +293,11 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void
/**
* @return array{seconds:string,fraction:string,sequence:int,machine_id:int}
*/
- private static function extractParts(string $id, int $startTimestamp): array
- {
+ private static function extractParts(
+ string $id,
+ int $startTimestamp,
+ SonyflakeFormat $format,
+ ): array {
$numericId = (int) $id;
$elapsed = $numericId >> 24;
$timestamp = $startTimestamp + ($elapsed * 10);
@@ -216,8 +305,12 @@ private static function extractParts(string $id, int $startTimestamp): array
return [
'seconds' => (string) intdiv($timestamp, 1000),
'fraction' => (string) (($timestamp % 1000) * 1000),
- 'sequence' => $numericId & 0xff,
- 'machine_id' => ($numericId >> 8) & 0xffff,
+ 'sequence' => $format === SonyflakeFormat::UPSTREAM
+ ? ($numericId >> 16) & 0xff
+ : $numericId & 0xff,
+ 'machine_id' => $format === SonyflakeFormat::UPSTREAM
+ ? $numericId & 0xffff
+ : ($numericId >> 8) & 0xffff,
];
}
@@ -229,77 +322,96 @@ private static function generateInternal(
int $startTimestamp,
ClockBackwardPolicy $clockBackwardPolicy,
?SequenceProviderInterface $sequenceProvider = null,
+ ?GenerationContext $runtime = null,
+ SonyflakeFormat $format = SonyflakeFormat::UID,
): string {
- $maxMachineID = -1 ^ (-1 << self::MACHINE_BITS);
- if ($machineId < 0 || $machineId > $maxMachineID) {
- throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID.");
- }
-
- $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider);
- $currentTime = (int) floor(microtime(true) * 1000);
- $domainKey = $startTimestamp . ':' . $machineId . ':' . spl_object_id($resolvedSequenceProvider);
- $lastWallTime = self::$lastWallTimeByDomain[$domainKey] ?? 0;
- if ($currentTime < $lastWallTime) {
- if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) {
- throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID');
- }
-
- $currentTime = self::waitUntilWallTime($lastWallTime);
- }
-
+ self::assertMachineId($machineId);
+
+ $provider = self::resolveSequenceProvider($sequenceProvider);
+ $domainKey = $format->value . ':' . $startTimestamp . ':' . $machineId;
+ $providerState = self::providerState($provider, $domainKey);
+ $currentTime = self::resolveWallTime(
+ self::nowMilliseconds($runtime),
+ $providerState[$domainKey] ?? 0,
+ $clockBackwardPolicy,
+ $runtime,
+ );
$elapsedTime = self::elapsedTime($currentTime, $startTimestamp);
self::ensureEffectiveRuntime($elapsedTime);
- $sequenceType = 'sonyflake_' . $startTimestamp;
-
- while (true) {
- try {
- $sequence = self::sequence(
- $elapsedTime,
- $machineId,
- $sequenceType,
- $resolvedSequenceProvider,
- );
- } catch (SequenceTimestampException $exception) {
- if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) {
- throw new SonyflakeException(
- 'Clock moved backwards while generating Sonyflake ID',
- 0,
- $exception,
- );
- }
-
- $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp);
- continue;
- }
+ [$elapsedTime, $sequence] = self::allocateSequence(
+ $provider,
+ $machineId,
+ $startTimestamp,
+ $elapsedTime,
+ $clockBackwardPolicy,
+ $runtime,
+ $format,
+ );
+ $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10));
+ self::ensureEffectiveRuntime($elapsedTime);
- if ($sequence < 1) {
- throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation');
- }
+ return self::packId($elapsedTime, $machineId, $sequence, $format);
+ }
- if ($sequence <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) {
- --$sequence;
+ /**
+ * Retrieves the start timestamp.
+ */
+ private static function getStartTimeStamp(SonyflakeFormat $format): int
+ {
+ return $format === SonyflakeFormat::UPSTREAM
+ ? self::UPSTREAM_DEFAULT_EPOCH
+ : self::DEFAULT_EPOCH;
+ }
- break;
- }
+ private static function nowMilliseconds(?GenerationContext $runtime): int
+ {
+ return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000);
+ }
- $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp);
+ private static function packId(
+ int $elapsedTime,
+ int $machineId,
+ int $sequence,
+ SonyflakeFormat $format,
+ ): string {
+ if ($format === SonyflakeFormat::UPSTREAM) {
+ return (string) (
+ ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS))
+ | ($sequence << self::MACHINE_BITS)
+ | $machineId
+ );
}
- self::$lastWallTimeByDomain[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10));
- self::ensureEffectiveRuntime($elapsedTime);
-
- return (string) ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS)
+ return (string) (
+ ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS))
| ($machineId << self::SEQUENCE_BITS)
- | ($sequence));
+ | $sequence
+ );
}
/**
- * Retrieves the start timestamp.
+ * @return \ArrayObject
*/
- private static function getStartTimeStamp(): int
- {
- return self::DEFAULT_EPOCH;
+ private static function providerState(
+ SequenceProviderInterface $provider,
+ string $domainKey,
+ ): \ArrayObject {
+ self::$lastWallTimeByProvider ??= new \WeakMap();
+
+ /** @var \ArrayObject|null $state */
+ $state = self::$lastWallTimeByProvider[$provider] ?? null;
+ if ($state === null) {
+ /** @var \ArrayObject $state */
+ $state = new \ArrayObject();
+ self::$lastWallTimeByProvider[$provider] = $state;
+ }
+
+ if (!isset($state[$domainKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) {
+ throw new SonyflakeException('Sonyflake provider domain limit exceeded');
+ }
+
+ return $state;
}
private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface
@@ -307,23 +419,61 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov
return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider();
}
- private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int
- {
- $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp);
- while ($next <= $elapsedTime) {
- usleep(1000);
- $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp);
+ private static function resolveWallTime(
+ int $currentTime,
+ int $lastWallTime,
+ ClockBackwardPolicy $policy,
+ ?GenerationContext $runtime,
+ ): int {
+ if ($currentTime >= $lastWallTime) {
+ return $currentTime;
+ }
+ if ($policy === ClockBackwardPolicy::THROW) {
+ throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID');
+ }
+
+ return self::waitUntilWallTime($lastWallTime, $runtime);
+ }
+
+ private static function waitUntilElapsed(
+ int $elapsedTime,
+ int $startTimestamp,
+ ?GenerationContext $runtime,
+ ): int {
+ $deadline = $runtime?->waitDeadlineNanoseconds()
+ ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000);
+
+ while (($next = self::elapsedTime(self::nowMilliseconds($runtime), $startTimestamp)) <= $elapsedTime) {
+ if (hrtime(true) >= $deadline) {
+ throw new SonyflakeException('Timed out waiting for the next Sonyflake timestamp');
+ }
+
+ if ($runtime !== null) {
+ $runtime->sleepMicroseconds(1_000);
+ } else {
+ usleep(1_000);
+ }
}
return $next;
}
- private static function waitUntilWallTime(int $lastTime): int
+ private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int
{
- do {
- usleep(1000);
- $currentTime = (int) floor(microtime(true) * 1000);
- } while ($currentTime < $lastTime);
+ $deadline = $runtime?->waitDeadlineNanoseconds()
+ ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000);
+
+ while (($currentTime = self::nowMilliseconds($runtime)) < $lastTime) {
+ if (hrtime(true) >= $deadline) {
+ throw new SonyflakeException('Timed out waiting for the Sonyflake clock to recover');
+ }
+
+ if ($runtime !== null) {
+ $runtime->sleepMicroseconds(1_000);
+ } else {
+ usleep(1_000);
+ }
+ }
return $currentTime;
}
diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php
index 87299b3..384839b 100644
--- a/src/Support/BaseEncoder.php
+++ b/src/Support/BaseEncoder.php
@@ -8,7 +8,7 @@
final class BaseEncoder
{
- private const ALPHABETS = [
+ private const array ALPHABETS = [
10 => '0123456789',
16 => '0123456789abcdef',
32 => '0123456789abcdefghijklmnopqrstuv',
@@ -17,17 +17,22 @@ final class BaseEncoder
62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
];
- private const MAX_BYTE_LENGTH = 1024;
+ private const int MAX_BYTE_LENGTH = 1024;
+
+ /** @var array */
+ private const array RADIX_GROUPS = [
+ 10 => [1_000_000_000, 9],
+ 32 => [1_073_741_824, 6],
+ 36 => [60_466_176, 5],
+ 58 => [656_356_768, 5],
+ 62 => [916_132_832, 5],
+ ];
- /**
- * Decodes one of supported bases (16/32/36/58/62) into bytes.
- */
public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string
{
if ($encoded === '') {
throw new InvalidArgumentException('Encoded value must not be empty');
}
-
if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) {
throw new InvalidArgumentException('Byte length must be between 1 and 1024');
}
@@ -43,23 +48,76 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt
return $decoded;
}
+ return self::decodeRadix($encoded, $base, $bytesLength);
+ }
+
+ public static function encodeBytes(string $bytes, int $base): string
+ {
+ self::assertByteLength(strlen($bytes));
+
+ if ($base === 16) {
+ return ltrim(bin2hex($bytes), '0') ?: '0';
+ }
+
+ $alphabet = self::alphabet($base);
+ [$radix, $width] = self::RADIX_GROUPS[$base];
+ $padding = (4 - strlen($bytes) % 4) % 4;
+ $number = BinaryUnpack::words(str_repeat("\0", $padding) . $bytes);
+ $encoded = '';
+ while ($number !== []) {
+ $quotient = [];
+ $remainder = 0;
+
+ foreach ($number as $word) {
+ // Each radix is at most 2^30, keeping the combined value below 2^62.
+ $value = ($remainder << 32) | $word;
+ $digit = intdiv($value, $radix);
+ $remainder = $value % $radix;
+
+ if ($quotient !== [] || $digit !== 0) {
+ $quotient[] = $digit;
+ }
+ }
+
+ $chunk = self::encodeGroup($remainder, $base, $alphabet);
+ $encoded = ($quotient === [] ? $chunk : str_pad($chunk, $width, $alphabet[0], STR_PAD_LEFT)) . $encoded;
+ $number = $quotient;
+ }
+
+ return $encoded;
+ }
+
+ private static function alphabet(int $base): string
+ {
+ return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base);
+ }
+
+ private static function assertByteLength(int $byteLength): void
+ {
+ if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) {
+ throw new InvalidArgumentException('Byte length must be between 1 and 1024');
+ }
+ }
+
+ private static function decodeRadix(string $encoded, int $base, int $bytesLength): string
+ {
$alphabet = self::alphabet($base);
- $maxEncodedLength = (int) ceil(($bytesLength * 8) / log($base, 2));
- if (strlen($encoded) > $maxEncodedLength) {
+ $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2));
+ if (strlen($encoded) > $maximumLength) {
throw new InvalidArgumentException('Encoded value exceeds target byte length');
}
$bytes = [0];
- $encodedLength = strlen($encoded);
+ $length = strlen($encoded);
- for ($index = 0; $index < $encodedLength; ++$index) {
- $char = $encoded[$index];
- $alphabetIndex = strpos($alphabet, $char);
- $alphabetIndex !== false || throw new InvalidArgumentException('Invalid character for base ' . $base);
+ for ($index = 0; $index < $length; ++$index) {
+ $digit = strpos($alphabet, $encoded[$index]);
+ if ($digit === false) {
+ throw new InvalidArgumentException('Invalid character for base ' . $base);
+ }
- $carry = $alphabetIndex;
- $byteCount = count($bytes);
- for ($byteIndex = $byteCount - 1; $byteIndex >= 0; --$byteIndex) {
+ $carry = $digit;
+ for ($byteIndex = count($bytes) - 1; $byteIndex >= 0; --$byteIndex) {
$value = ($bytes[$byteIndex] * $base) + $carry;
$bytes[$byteIndex] = $value & 0xff;
$carry = $value >> 8;
@@ -83,55 +141,14 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt
return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded;
}
- /**
- * Encodes bytes into one of supported bases (16/32/36/58/62).
- */
- public static function encodeBytes(string $bytes, int $base): string
+ private static function encodeGroup(int $value, int $base, string $alphabet): string
{
- $byteLength = strlen($bytes);
- if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) {
- throw new InvalidArgumentException('Byte length must be between 1 and 1024');
- }
-
- if ($base === 16) {
- return ltrim(bin2hex($bytes), '0') ?: '0';
- }
-
- $alphabet = self::alphabet($base);
- $unpacked = unpack('C*', $bytes);
- $unpacked !== false || throw new \LogicException('Unable to unpack byte value');
- $number = [];
- foreach ($unpacked as $byte) {
- is_int($byte) || throw new \LogicException('Unable to unpack byte value');
- $number[] = $byte;
- }
-
- if (trim($bytes, "\0") === '') {
- return $alphabet[0];
- }
-
$encoded = '';
- while ($number !== []) {
- $quotient = [];
- $remainder = 0;
- foreach ($number as $byte) {
- $value = ($remainder << 8) | $byte;
- $digit = intdiv($value, $base);
- $remainder = $value % $base;
- if ($quotient !== [] || $digit !== 0) {
- $quotient[] = $digit;
- }
- }
-
- $encoded = $alphabet[$remainder] . $encoded;
- $number = $quotient;
- }
+ do {
+ $encoded = $alphabet[$value % $base] . $encoded;
+ $value = intdiv($value, $base);
+ } while ($value > 0);
return $encoded;
}
-
- private static function alphabet(int $base): string
- {
- return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base);
- }
}
diff --git a/src/Support/BinaryUnpack.php b/src/Support/BinaryUnpack.php
index ec4e1e0..d2e3650 100644
--- a/src/Support/BinaryUnpack.php
+++ b/src/Support/BinaryUnpack.php
@@ -6,6 +6,18 @@
final class BinaryUnpack
{
+ /**
+ * @return list
+ */
+ public static function bytes(string $bytes): array
+ {
+ /** @var array|false $unpacked */
+ $unpacked = unpack('C*', $bytes);
+ $unpacked !== false || throw new \LogicException('Unable to unpack byte value');
+
+ return array_values($unpacked);
+ }
+
/**
* @throws \Exception
*/
@@ -30,6 +42,18 @@ public static function u32(string $bytes, string $error): int
return self::value(unpack('N', $bytes), $error);
}
+ /**
+ * @return list
+ */
+ public static function words(string $bytes): array
+ {
+ /** @var array|false $unpacked */
+ $unpacked = unpack('N*', $bytes);
+ $unpacked !== false || throw new \LogicException('Unable to unpack word value');
+
+ return array_values($unpacked);
+ }
+
/**
* @param array|false $unpacked
* @throws \Exception
diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php
index b433fb1..f2e76c4 100644
--- a/src/Support/DecimalBytes.php
+++ b/src/Support/DecimalBytes.php
@@ -4,28 +4,124 @@
namespace Infocyph\UID\Support;
+use InvalidArgumentException;
+use LogicException;
+
final class DecimalBytes
{
- private const MAX_BYTE_LENGTH = 1024;
+ private const int MAX_BYTE_LENGTH = 1024;
+
+ private const string MAX_SIGNED_64 = '9223372036854775807';
+
+ private const string MAX_UNSIGNED_64 = '18446744073709551615';
+
+ private const string MIN_SIGNED_64_MAGNITUDE = '9223372036854775808';
+
+ private const string TWO_TO_64 = '18446744073709551616';
public static function fromBytes(string $bytes): string
{
return BaseEncoder::encodeBytes($bytes, 10);
}
+ public static function fromLittleEndianSigned64(string $bytes): string
+ {
+ if (strlen($bytes) !== 8) {
+ throw new InvalidArgumentException('Signed 64-bit value must contain exactly 8 bytes');
+ }
+
+ $unsigned = self::fromBytes(strrev($bytes));
+ if ((ord($bytes[7]) & 0x80) === 0) {
+ return $unsigned;
+ }
+
+ return '-' . self::subtract(self::TWO_TO_64, $unsigned);
+ }
+
+ public static function isSigned64(string $value): bool
+ {
+ if ($value === '') {
+ return false;
+ }
+
+ if ($value[0] === '-') {
+ $magnitude = substr($value, 1);
+
+ return $magnitude !== ''
+ && ctype_digit($magnitude)
+ && $magnitude[0] !== '0'
+ && UnsignedDecimal::compare($magnitude, self::MIN_SIGNED_64_MAGNITUDE) <= 0;
+ }
+
+ return ctype_digit($value)
+ && ($value === '0' || $value[0] !== '0')
+ && UnsignedDecimal::compare($value, self::MAX_SIGNED_64) <= 0;
+ }
+
/**
- * @throws \InvalidArgumentException
+ * @throws InvalidArgumentException
*/
public static function toFixedBytes(string $decimal, int $byteLength): string
{
if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) {
- throw new \InvalidArgumentException('Byte length must be between 1 and 1024');
+ throw new InvalidArgumentException('Byte length must be between 1 and 1024');
}
if ($decimal === '' || !ctype_digit($decimal)) {
- throw new \InvalidArgumentException('Decimal value must contain only digits');
+ throw new InvalidArgumentException('Decimal value must contain only digits');
}
return BaseEncoder::decodeToBytes(UnsignedDecimal::normalize($decimal), 10, $byteLength);
}
+
+ public static function toLittleEndianSigned64(string $value): string
+ {
+ if (!self::isSigned64($value)) {
+ throw new InvalidArgumentException('Value is outside the signed 64-bit domain');
+ }
+
+ $unsigned = $value[0] === '-'
+ ? self::subtract(self::TWO_TO_64, substr($value, 1))
+ : $value;
+
+ if (UnsignedDecimal::compare($unsigned, self::MAX_UNSIGNED_64) > 0) {
+ throw new InvalidArgumentException('Value is outside the unsigned 64-bit storage domain');
+ }
+
+ return strrev(self::toFixedBytes($unsigned, 8));
+ }
+
+ private static function subtract(string $left, string $right): string
+ {
+ if (UnsignedDecimal::compare($left, $right) < 0) {
+ throw new InvalidArgumentException('Unsigned subtraction would become negative');
+ }
+
+ $leftIndex = strlen($left) - 1;
+ $rightIndex = strlen($right) - 1;
+ $borrow = 0;
+ $result = '';
+
+ while ($leftIndex >= 0) {
+ $digit = (ord($left[$leftIndex]) - 48) - $borrow;
+ $rightDigit = $rightIndex >= 0 ? ord($right[$rightIndex]) - 48 : 0;
+ if ($digit < $rightDigit) {
+ $digit += 10;
+ $borrow = 1;
+ } else {
+ $borrow = 0;
+ }
+
+ $difference = $digit - $rightDigit;
+ if ($difference < 0 || $difference > 9) {
+ throw new LogicException('Signed decimal subtraction produced an invalid digit');
+ }
+
+ $result = $difference . $result;
+ --$leftIndex;
+ --$rightIndex;
+ }
+
+ return UnsignedDecimal::normalize($result);
+ }
}
diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php
index 2daee3e..ad61687 100644
--- a/src/Support/FileLock.php
+++ b/src/Support/FileLock.php
@@ -4,10 +4,14 @@
namespace Infocyph\UID\Support;
+use ErrorException;
use Infocyph\UID\Exceptions\FileLockException;
+use Infocyph\UID\Runtime\GenerationContext;
final class FileLock
{
+ private const int DEFAULT_TIMEOUT_MICROS = 1_000_000;
+
/**
* @return resource
* @throws FileLockException
@@ -17,37 +21,162 @@ public static function acquire(
?int $timeoutMicros,
string $openErrorMessage,
string $lockErrorMessage,
+ ?GenerationContext $runtime = null,
) {
- ($handle = fopen($path, 'c+')) || throw new FileLockException($openErrorMessage);
-
- if ($timeoutMicros === null) {
- if (flock($handle, LOCK_EX)) {
- return $handle;
- }
-
+ $runtime?->assertActive();
+ $handle = self::openVerified($path, $openErrorMessage);
+ $wouldBlock = 0;
+ if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) {
+ return $handle;
+ }
+ if ($wouldBlock !== 1) {
fclose($handle);
throw new FileLockException($lockErrorMessage);
}
- $deadline = hrtime(true) + ($timeoutMicros * 1000);
- do {
- $wouldBlock = 0;
- if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) {
- return $handle;
- }
+ try {
+ $deadline = self::lockDeadline($timeoutMicros, $runtime);
- if ($wouldBlock !== 1) {
- fclose($handle);
+ while (hrtime(true) < $deadline) {
+ $wouldBlock = 0;
+ if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) {
+ return $handle;
+ }
+ if ($wouldBlock !== 1) {
+ throw new FileLockException($lockErrorMessage);
+ }
- throw new FileLockException($lockErrorMessage);
+ if ($runtime !== null) {
+ $runtime->sleepMicroseconds(1_000);
+ } else {
+ usleep(1_000);
+ }
}
+ } catch (\Throwable $exception) {
+ fclose($handle);
- usleep(1000);
- } while (hrtime(true) < $deadline);
+ throw $exception;
+ }
fclose($handle);
throw new FileLockException($lockErrorMessage);
}
+
+ /**
+ * @param array $metadata
+ * @throws FileLockException
+ */
+ private static function assertSafeMetadata(array $metadata, string $errorMessage, ?int $ownerId): void
+ {
+ if (($metadata['mode'] & 0170000) !== 0100000) {
+ throw new FileLockException($errorMessage);
+ }
+
+ if ($ownerId !== null && $metadata['uid'] !== $ownerId) {
+ throw new FileLockException($errorMessage);
+ }
+ }
+
+ private static function lockDeadline(?int $timeoutMicros, ?GenerationContext $runtime): int
+ {
+ $timeout = $timeoutMicros ?? $runtime->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS;
+ if ($runtime !== null) {
+ $timeout = min($timeout, $runtime->waitTimeoutMicros);
+ }
+ $now = hrtime(true);
+ $deadline = $now + (min($timeout, intdiv(PHP_INT_MAX - $now, 1_000)) * 1_000);
+ $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds();
+
+ return $runtimeDeadline === null ? $deadline : min($deadline, $runtimeDeadline);
+ }
+
+ /**
+ * @return resource
+ * @throws FileLockException
+ */
+ private static function openVerified(string $path, string $errorMessage)
+ {
+ $ownerId = function_exists('posix_geteuid') ? posix_geteuid() : null;
+ set_error_handler(
+ static function (int $severity, string $message, string $file, int $line): never {
+ throw new ErrorException($message, 0, $severity, $file, $line);
+ },
+ );
+
+ try {
+ // Refresh metadata; handle identity checks also fence cached path resolution.
+ clearstatcache();
+
+ try {
+ $before = lstat($path);
+ } catch (ErrorException) {
+ $before = false;
+ }
+
+ if ($before !== false) {
+ self::assertSafeMetadata($before, $errorMessage, $ownerId);
+ $handle = fopen($path, 'r+b');
+ is_resource($handle) || throw new FileLockException($errorMessage);
+
+ return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId);
+ }
+
+ try {
+ $handle = fopen($path, 'x+b');
+ } catch (ErrorException) {
+ $before = lstat($path);
+ $before !== false || throw new FileLockException($errorMessage);
+ self::assertSafeMetadata($before, $errorMessage, $ownerId);
+
+ $handle = fopen($path, 'r+b');
+ is_resource($handle) || throw new FileLockException($errorMessage);
+
+ return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId);
+ }
+
+ is_resource($handle) || throw new FileLockException($errorMessage);
+ chmod($path, 0600) || throw new FileLockException($errorMessage);
+
+ return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId);
+ } catch (ErrorException $exception) {
+ throw new FileLockException($errorMessage, 0, $exception);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * @param resource $handle
+ * @param array|null $before
+ * @return resource
+ */
+ private static function verifyHandle(string $path, $handle, ?array $before, string $errorMessage, ?int $ownerId)
+ {
+ try {
+ $after = fstat($handle);
+ clearstatcache();
+ $pathState = lstat($path);
+ if ($after === false || $pathState === false) {
+ throw new FileLockException($errorMessage);
+ }
+
+ if (
+ ($after['mode'] & 0170000) !== 0100000
+ || ($pathState['mode'] & 0170000) !== 0100000
+ || ($ownerId !== null && ($after['uid'] !== $ownerId || $pathState['uid'] !== $ownerId))
+ || $after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino']
+ || ($before !== null && ($before['dev'] !== $after['dev'] || $before['ino'] !== $after['ino']))
+ ) {
+ throw new FileLockException($errorMessage);
+ }
+
+ return $handle;
+ } catch (\Throwable $exception) {
+ fclose($handle);
+
+ throw $exception;
+ }
+ }
}
diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php
index 0087315..e94b37b 100644
--- a/src/Support/GetSequence.php
+++ b/src/Support/GetSequence.php
@@ -4,6 +4,7 @@
namespace Infocyph\UID\Support;
+use Infocyph\UID\Runtime\GenerationContext;
use Infocyph\UID\Sequence\CallbackSequenceProvider;
use Infocyph\UID\Sequence\FilesystemSequenceProvider;
use Infocyph\UID\Sequence\InMemorySequenceProvider;
@@ -39,12 +40,14 @@ public static function useFilesystemSequenceProvider(
string $namespace = '',
?int $lockTimeoutMicros = null,
int $reservationSize = 1,
+ ?GenerationContext $runtime = null,
): void {
self::$sequenceProvider = new FilesystemSequenceProvider(
$baseDirectory,
$namespace,
$lockTimeoutMicros,
$reservationSize,
+ $runtime,
);
}
@@ -75,6 +78,7 @@ public static function useSimpleCacheSequenceProvider(
?callable $synchronizer = null,
int $waitTime = 1_000,
int $maxAttempts = 1_000,
+ ?GenerationContext $runtime = null,
): void {
self::$sequenceProvider = new PsrSimpleCacheSequenceProvider(
$cache,
@@ -82,6 +86,7 @@ public static function useSimpleCacheSequenceProvider(
$waitTime,
$maxAttempts,
$synchronizer,
+ $runtime,
);
}
@@ -96,9 +101,12 @@ private static function sequence(
int $dateTime,
int $machineId,
string $type,
- ?SequenceProviderInterface $provider = null,
+ SequenceProviderInterface $provider,
+ ?GenerationContext $runtime = null,
): int {
- $provider ??= self::$sequenceProvider ??= new FilesystemSequenceProvider();
+ if ($runtime !== null && ($provider instanceof FilesystemSequenceProvider || $provider instanceof PsrSimpleCacheSequenceProvider)) {
+ return $provider->next($type, $machineId, $dateTime, $runtime);
+ }
return $provider->next($type, $machineId, $dateTime);
}
diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php
new file mode 100644
index 0000000..78f6705
--- /dev/null
+++ b/src/Support/Sparx64.php
@@ -0,0 +1,165 @@
+> */
+ private array $subkeys;
+
+ public function __construct(#[\SensitiveParameter] string $key)
+ {
+ if (strlen($key) !== 16) {
+ throw new InvalidArgumentException('SPARX64 key must be exactly 16 bytes');
+ }
+
+ $master = [];
+ for ($index = 0; $index < 16; $index += 2) {
+ $master[] = (ord($key[$index]) << 8) | ord($key[$index + 1]);
+ }
+
+ $this->subkeys = [];
+ for ($counter = 0; $counter < (self::BRANCHES * self::STEPS) + 1; ++$counter) {
+ $this->subkeys[$counter] = array_slice($master, 0, 2 * self::ROUNDS_PER_STEP);
+ self::permuteKey($master, $counter + 1);
+ }
+ }
+
+ public function decrypt(string $block): string
+ {
+ $state = self::unpackBlock($block);
+ $last = self::BRANCHES * self::STEPS;
+ for ($branch = 0; $branch < self::BRANCHES; ++$branch) {
+ $state[2 * $branch] ^= $this->subkeys[$last][2 * $branch];
+ $state[(2 * $branch) + 1] ^= $this->subkeys[$last][(2 * $branch) + 1];
+ }
+
+ for ($step = self::STEPS - 1; $step >= 0; --$step) {
+ self::linearInverse($state);
+ for ($branch = 0; $branch < self::BRANCHES; ++$branch) {
+ for ($round = self::ROUNDS_PER_STEP - 1; $round >= 0; --$round) {
+ self::roundInverse($state[2 * $branch], $state[(2 * $branch) + 1]);
+ $state[2 * $branch] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][2 * $round];
+ $state[(2 * $branch) + 1] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][(2 * $round) + 1];
+ }
+ }
+ }
+
+ return self::packBlock($state);
+ }
+
+ public function encrypt(string $block): string
+ {
+ $state = self::unpackBlock($block);
+ for ($step = 0; $step < self::STEPS; ++$step) {
+ for ($branch = 0; $branch < self::BRANCHES; ++$branch) {
+ for ($round = 0; $round < self::ROUNDS_PER_STEP; ++$round) {
+ $state[2 * $branch] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][2 * $round];
+ $state[(2 * $branch) + 1] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][(2 * $round) + 1];
+ self::round($state[2 * $branch], $state[(2 * $branch) + 1]);
+ }
+ }
+
+ self::linear($state);
+ }
+
+ $last = self::BRANCHES * self::STEPS;
+ for ($branch = 0; $branch < self::BRANCHES; ++$branch) {
+ $state[2 * $branch] ^= $this->subkeys[$last][2 * $branch];
+ $state[(2 * $branch) + 1] ^= $this->subkeys[$last][(2 * $branch) + 1];
+ }
+
+ return self::packBlock($state);
+ }
+
+ /** @param array $state */
+ private static function linear(array &$state): void
+ {
+ $temporary = self::rotateLeft16($state[0] ^ $state[1], 8);
+ $state[2] ^= $state[0] ^ $temporary;
+ $state[3] ^= $state[1] ^ $temporary;
+ [$state[0], $state[2]] = [$state[2] & 0xffff, $state[0] & 0xffff];
+ [$state[1], $state[3]] = [$state[3] & 0xffff, $state[1] & 0xffff];
+ }
+
+ /** @param array $state */
+ private static function linearInverse(array &$state): void
+ {
+ [$state[0], $state[2]] = [$state[2], $state[0]];
+ [$state[1], $state[3]] = [$state[3], $state[1]];
+ $temporary = self::rotateLeft16($state[0] ^ $state[1], 8);
+ $state[2] = ($state[2] ^ $state[0] ^ $temporary) & 0xffff;
+ $state[3] = ($state[3] ^ $state[1] ^ $temporary) & 0xffff;
+ }
+
+ /** @param array $state */
+ private static function packBlock(array $state): string
+ {
+ $output = '';
+ foreach ($state as $value) {
+ $output .= chr(($value >> 8) & 0xff) . chr($value & 0xff);
+ }
+
+ return $output;
+ }
+
+ /** @param array $key */
+ private static function permuteKey(array &$key, int $counter): void
+ {
+ self::round($key[0], $key[1]);
+ $key[2] = ($key[2] + $key[0]) & 0xffff;
+ $key[3] = ($key[3] + $key[1]) & 0xffff;
+ $key[7] = ($key[7] + $counter) & 0xffff;
+ $six = $key[6];
+ $seven = $key[7];
+ for ($index = 7; $index >= 2; --$index) {
+ $key[$index] = $key[$index - 2];
+ }
+ $key[0] = $six;
+ $key[1] = $seven;
+ }
+
+ private static function rotateLeft16(int $value, int $bits): int
+ {
+ $value &= 0xffff;
+
+ return (($value << $bits) | ($value >> (16 - $bits))) & 0xffff;
+ }
+
+ private static function round(int &$left, int &$right): void
+ {
+ $left = (self::rotateLeft16($left, 9) + $right) & 0xffff;
+ $right = (self::rotateLeft16($right, 2) ^ $left) & 0xffff;
+ }
+
+ private static function roundInverse(int &$left, int &$right): void
+ {
+ $right = self::rotateLeft16($right ^ $left, 14);
+ $left = self::rotateLeft16(($left - $right) & 0xffff, 7);
+ }
+
+ /** @return array */
+ private static function unpackBlock(string $block): array
+ {
+ if (strlen($block) !== 8) {
+ throw new InvalidArgumentException('SPARX64 block must be exactly 8 bytes');
+ }
+
+ return [
+ (ord($block[0]) << 8) | ord($block[1]),
+ (ord($block[2]) << 8) | ord($block[3]),
+ (ord($block[4]) << 8) | ord($block[5]),
+ (ord($block[6]) << 8) | ord($block[7]),
+ ];
+ }
+}
diff --git a/src/Support/TypeIdCodec.php b/src/Support/TypeIdCodec.php
index e0f70a2..9c6e943 100644
--- a/src/Support/TypeIdCodec.php
+++ b/src/Support/TypeIdCodec.php
@@ -8,7 +8,7 @@
final class TypeIdCodec
{
- private const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
+ private const string ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
public static function decode(string $suffix): string
{
diff --git a/src/TBSL.php b/src/TBSL.php
index a8fdfa8..45ecde4 100644
--- a/src/TBSL.php
+++ b/src/TBSL.php
@@ -10,6 +10,8 @@
use Infocyph\UID\Enums\ClockBackwardPolicy;
use Infocyph\UID\Exceptions\SequenceTimestampException;
use Infocyph\UID\Exceptions\UIDException;
+use Infocyph\UID\Runtime\GenerationContext;
+use Infocyph\UID\Sequence\FilesystemSequenceProvider;
use Infocyph\UID\Sequence\SequenceProviderInterface;
use Infocyph\UID\Support\BaseEncoder;
use Infocyph\UID\Support\GetSequence;
@@ -18,7 +20,10 @@ final class TBSL
{
use GetSequence;
- private static int $lastTimeSequence = 0;
+ private const int WAIT_TIMEOUT_MICROS = 1_000_000;
+
+ /** @var \WeakMap>|null */
+ private static ?\WeakMap $lastTimeByProvider = null;
/**
* Decodes one of bases: 16, 32, 36, 58, 62 into canonical TBSL.
@@ -78,6 +83,7 @@ public static function generateWithConfig(TBSLConfig $config): string
$config->sequenced,
$config->clockBackwardPolicy,
$config->sequenceProvider,
+ $config->runtime,
);
}
@@ -86,7 +92,7 @@ public static function generateWithConfig(TBSLConfig $config): string
*/
public static function isValid(string $tbsl): bool
{
- return (bool) preg_match('/^[0-9A-F]{20}$/', $tbsl);
+ return (bool) preg_match('/^[0-9A-F]{20}$/D', $tbsl);
}
/**
@@ -107,11 +113,11 @@ public static function parse(string $tbsl): array
$storeParts = unpack('Jvalue', $storeBytes);
$storeValue = $storeParts['value'] ?? null;
is_int($storeValue) || throw new Exception('Unable to parse TBSL timestamp');
- $storeData = str_pad((string) $storeValue, 18, '0', STR_PAD_LEFT);
+ $time = intdiv($storeValue, 100);
return [
- 'time' => new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)),
- 'machineId' => (int) substr($storeData, -2),
+ 'time' => new DateTimeImmutable('@' . intdiv($time, 1_000_000) . '.' . str_pad((string) ($time % 1_000_000), 6, '0', STR_PAD_LEFT)),
+ 'machineId' => $storeValue % 100,
];
}
@@ -152,6 +158,13 @@ private static function assertMachineId(int $machineId): void
}
}
+ private static function assertTimestamp(int $timestamp, int $machineId): void
+ {
+ if ($timestamp < 0 || $timestamp > intdiv(0x0fffffffffffffff - $machineId, 100)) {
+ throw new UIDException('TBSL timestamp exceeds its 60-bit field');
+ }
+ }
+
/**
* @throws Exception
*/
@@ -160,29 +173,37 @@ private static function generateInternal(
bool $sequenced,
ClockBackwardPolicy $clockBackwardPolicy,
?SequenceProviderInterface $sequenceProvider = null,
+ ?GenerationContext $runtime = null,
): string {
self::assertMachineId($machineId);
- [$micro, $seconds] = explode(' ', microtime());
- $timeSequence = (int) ($seconds . substr($micro, 2, 6));
+ $sequenceProvider ??= self::$sequenceProvider ??= new FilesystemSequenceProvider();
+ self::$lastTimeByProvider ??= new \WeakMap();
+ /** @var \ArrayObject $state */
+ $state = self::$lastTimeByProvider[$sequenceProvider] ??= new \ArrayObject();
+ $lastTime = $state[$machineId] ?? 0;
+ $timeSequence = self::nowMicroseconds($runtime);
- if ($timeSequence < self::$lastTimeSequence) {
+ if ($timeSequence < $lastTime) {
if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) {
throw new UIDException('Clock moved backwards while generating TBSL ID');
}
- $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence);
+ $timeSequence = self::waitUntilNextTimeSequence($lastTime, $runtime);
}
+ self::assertTimestamp($timeSequence, $machineId);
[$timeSequence, $tail] = self::resolveTail(
$machineId,
$sequenced,
$timeSequence,
$clockBackwardPolicy,
$sequenceProvider,
+ $runtime,
);
- self::$lastTimeSequence = $timeSequence;
+ self::assertTimestamp($timeSequence, $machineId);
+ $state[$machineId] = max($state[$machineId] ?? 0, $timeSequence);
- $storeValue = (int) ($timeSequence . sprintf('%02d', $machineId));
+ $storeValue = ($timeSequence * 100) + $machineId;
$storeData = ltrim(bin2hex(pack('J', $storeValue)), '0');
if (strlen($storeData) > 15) {
throw new UIDException('TBSL timestamp exceeds its 60-bit field');
@@ -195,6 +216,11 @@ private static function generateInternal(
));
}
+ private static function nowMicroseconds(?GenerationContext $runtime): int
+ {
+ return $runtime?->nowMicroseconds() ?? (int) floor(microtime(true) * 1_000_000);
+ }
+
/**
* Generates a sequence or random bytes based on the sequencing flag.
*
@@ -209,7 +235,8 @@ private static function resolveTail(
bool $enableSequence,
int $timeSequence,
ClockBackwardPolicy $clockBackwardPolicy,
- ?SequenceProviderInterface $sequenceProvider = null,
+ SequenceProviderInterface $sequenceProvider,
+ ?GenerationContext $runtime = null,
): array {
if (!$enableSequence) {
return [$timeSequence, substr(bin2hex(random_bytes(3)), 0, 5)];
@@ -217,7 +244,7 @@ private static function resolveTail(
do {
try {
- $sequence = self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider);
+ $sequence = self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider, $runtime);
} catch (SequenceTimestampException $exception) {
if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) {
throw new UIDException(
@@ -227,7 +254,7 @@ private static function resolveTail(
);
}
- $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp);
+ $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp, $runtime);
continue;
}
@@ -240,16 +267,26 @@ private static function resolveTail(
return [$timeSequence, str_pad(dechex($sequence - 1), 5, '0', STR_PAD_LEFT)];
}
- $timeSequence = self::waitUntilNextTimeSequence($timeSequence);
+ $timeSequence = self::waitUntilNextTimeSequence($timeSequence, $runtime);
} while (true);
}
- private static function waitUntilNextTimeSequence(int $last): int
+ private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int
{
- do {
- [$micro, $seconds] = explode(' ', microtime());
- $candidate = (int) ($seconds . substr($micro, 2, 6));
- } while ($candidate <= $last);
+ $deadline = $runtime?->waitDeadlineNanoseconds()
+ ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000);
+
+ while (($candidate = self::nowMicroseconds($runtime)) <= $last) {
+ if (hrtime(true) >= $deadline) {
+ throw new UIDException('Timed out waiting for the next TBSL timestamp');
+ }
+
+ if ($runtime !== null) {
+ $runtime->sleepMicroseconds(100);
+ } else {
+ usleep(100);
+ }
+ }
return $candidate;
}
diff --git a/src/ULID.php b/src/ULID.php
index dafccde..a5f027a 100644
--- a/src/ULID.php
+++ b/src/ULID.php
@@ -13,15 +13,15 @@
final class ULID
{
- private const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+ private const string ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
- private const ENCODING_LENGTH = 32;
+ private const int ENCODING_LENGTH = 32;
- private const MAX_TIMESTAMP = 281_474_976_710_655;
+ private const int MAX_TIMESTAMP = 281_474_976_710_655;
- private const RANDOM_LENGTH = 16;
+ private const int RANDOM_LENGTH = 16;
- private const TIME_LENGTH = 10;
+ private const int TIME_LENGTH = 10;
private static int $lastGenTime = 0;
@@ -87,17 +87,19 @@ public static function generate(
self::assertTimestamp($time);
$isMonotonic = $mode === UlidGenerationMode::MONOTONIC;
- if ($isMonotonic && $dateTime === null && $time < self::$lastGenTime) {
- $time = self::$lastGenTime;
+ if (!$isMonotonic) {
+ return self::encodeTime($time) . self::randomChars();
}
- $isDuplicate = $isMonotonic && $time === self::$lastGenTime;
- if ($isMonotonic) {
- self::$lastGenTime = $time;
+ if ($dateTime === null && $time < self::$lastGenTime) {
+ $time = self::$lastGenTime;
}
+ $isDuplicate = $time === self::$lastGenTime;
+ self::$lastGenTime = $time;
+
$timeChars = self::encodeTime($time);
- if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::RANDOM_LENGTH) {
+ if (!$isDuplicate || count(self::$lastRandChars) !== self::RANDOM_LENGTH) {
self::resetRandomState();
} elseif (!self::incrementRandomState()) {
if ($dateTime !== null) {
@@ -105,6 +107,7 @@ public static function generate(
}
$time = self::waitForNextMillisecond(self::$lastGenTime);
+ self::assertTimestamp($time);
self::$lastGenTime = $time;
$timeChars = self::encodeTime($time);
self::resetRandomState();
@@ -168,7 +171,7 @@ public static function getTime(string $ulid): DateTimeImmutable
*/
public static function isValid(string $ulid): bool
{
- return (bool) preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/', $ulid);
+ return (bool) preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/D', $ulid);
}
/**
@@ -236,19 +239,43 @@ private static function encodeTime(int $time): string
private static function incrementRandomState(): bool
{
+ $next = self::$lastRandChars;
for ($index = self::RANDOM_LENGTH - 1; $index >= 0; --$index) {
- if (self::$lastRandChars[$index] < 31) {
- self::$lastRandChars[$index]++;
+ if ($next[$index] < 31) {
+ $next[$index]++;
+ self::$lastRandChars = $next;
return true;
}
- self::$lastRandChars[$index] = 0;
+ $next[$index] = 0;
}
return false;
}
+ /**
+ * @throws Exception
+ */
+ private static function randomChars(): string
+ {
+ $random = random_bytes(10);
+ $result = '';
+ $buffer = 0;
+ $bits = 0;
+ for ($index = 0; $index < 10; ++$index) {
+ $buffer = ($buffer << 8) | ord($random[$index]);
+ $bits += 8;
+ while ($bits >= 5) {
+ $bits -= 5;
+ $result .= self::ENCODING_CHARS[($buffer >> $bits) & 31];
+ $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1;
+ }
+ }
+
+ return $result;
+ }
+
private static function randomCharsFromState(): string
{
$randChars = '';
@@ -293,7 +320,14 @@ private static function resetRandomState(): void
private static function waitForNextMillisecond(int $lastTimestamp): int
{
+ if ($lastTimestamp >= self::MAX_TIMESTAMP) {
+ throw new ULIDException('ULID timestamp exhausted');
+ }
+ $deadline = hrtime(true) + 1_000_000_000;
do {
+ if (hrtime(true) >= $deadline) {
+ throw new ULIDException('Timed out waiting for the next ULID timestamp');
+ }
usleep(1000);
$next = (int) floor(microtime(true) * 1000);
} while ($next <= $lastTimestamp);
diff --git a/src/UUID.php b/src/UUID.php
index 00fefc9..4fef6e7 100644
--- a/src/UUID.php
+++ b/src/UUID.php
@@ -14,26 +14,26 @@
final class UUID
{
- private const MAX_V7_TIMESTAMP = 281_474_976_710_655;
+ private const int MAX_V7_TIMESTAMP = 281_474_976_710_655;
- private const NS_LIST = [
+ private const array NS_LIST = [
'dns' => 0,
'url' => 1,
'oid' => 2,
'x500' => 4,
];
- private const RANDOM_LENGTH = [
+ private const array RANDOM_LENGTH = [
6 => 2,
7 => 4,
8 => 1,
];
- private const SECOND_INTERVALS = 10_000_000;
+ private const int SECOND_INTERVALS = 10_000_000;
- private const SECOND_INTERVALS_78 = 10_000;
+ private const int SECOND_INTERVALS_78 = 10_000;
- private const TIME_OFFSET = 0x01b21dd213814000;
+ private const int TIME_OFFSET = 0x01b21dd213814000;
/** @var array */
private static array $subSec = [
@@ -137,7 +137,7 @@ public static function guid(bool $trim = true): string
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
$data = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
- return $trim ? $data : "\{$data\}";
+ return $trim ? $data : '{' . $data . '}';
}
/**
@@ -550,41 +550,20 @@ private static function getUnixTimeSubSec(int $version = 1): array
*
* @return string|null The incremented value or null if overflow occurred.
*/
- private static function incrementHexCounter(string $hex): ?string
+ private static function incrementV7Tail(string $tail): ?string
{
- $hex = strtolower($hex);
- for ($index = strlen($hex) - 1; $index >= 0; --$index) {
- if ($hex[$index] === 'f') {
- $hex[$index] = '0';
+ $tail = strtolower($tail);
+ for ($index = strlen($tail) - 1; $index >= 1; --$index) {
+ $value = intval($tail[$index], 16);
+ $maximum = $index === 4 ? 3 : 15;
+ $value = $index === 4 ? $value & 3 : $value;
+ if ($value < $maximum) {
+ $tail[$index] = dechex($value + 1);
- continue;
+ return $tail;
}
- $next = match ($hex[$index]) {
- '0' => '1',
- '1' => '2',
- '2' => '3',
- '3' => '4',
- '4' => '5',
- '5' => '6',
- '6' => '7',
- '7' => '8',
- '8' => '9',
- '9' => 'a',
- 'a' => 'b',
- 'b' => 'c',
- 'c' => 'd',
- 'd' => 'e',
- 'e' => 'f',
- default => null,
- };
- if ($next === null) {
- return null;
- }
-
- $hex[$index] = $next;
-
- return $hex;
+ $tail[$index] = '0';
}
return null;
@@ -659,7 +638,7 @@ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimest
}
$unixTsMs = $state['timestamp'];
- $tail = self::incrementHexCounter($state['tail']);
+ $tail = self::incrementV7Tail($state['tail']);
if ($tail === null) {
if ($isExplicitTimestamp) {
throw new UUIDException('Monotonic UUID v7 overflow for the provided timestamp');
@@ -679,11 +658,22 @@ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimest
*/
private static function nextV7Timestamp(int $lastTimestamp): int
{
+ if ($lastTimestamp >= self::MAX_V7_TIMESTAMP) {
+ throw new UUIDException('UUID v7 timestamp exhausted');
+ }
+ $deadline = hrtime(true) + 1_000_000_000;
do {
+ if (hrtime(true) >= $deadline) {
+ throw new UUIDException('Timed out waiting for the next UUID v7 timestamp');
+ }
usleep(1000);
$next = (int) floor(microtime(true) * 1000);
} while ($next <= $lastTimestamp);
+ if ($next > self::MAX_V7_TIMESTAMP) {
+ throw new UUIDException('UUID v7 timestamp exhausted');
+ }
+
return $next;
}
@@ -714,7 +704,7 @@ private static function normalizeInputToHex(string $uuid): string
*/
private static function normalizeNode(string $node): string
{
- if (!preg_match('/^[0-9a-f]{12}$/i', $node)) {
+ if (!preg_match('/^[0-9a-f]{12}$/iD', $node)) {
throw new UUIDException('UUID node must be exactly 12 hexadecimal characters');
}
@@ -800,6 +790,10 @@ private static function randomLengthFor(int $version): int
*/
private static function randomV7Tail(): string
{
- return bin2hex(random_bytes(self::randomLengthFor(7) + 6));
+ $tail = bin2hex(random_bytes(self::randomLengthFor(7) + 6));
+ $tail[0] = '0';
+ $tail[4] = dechex(hexdec($tail[4]) & 3);
+
+ return $tail;
}
}
diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php
index e6cb017..0f22796 100644
--- a/src/Value/AbstractParsedIdValue.php
+++ b/src/Value/AbstractParsedIdValue.php
@@ -5,25 +5,31 @@
namespace Infocyph\UID\Value;
use Infocyph\UID\Contracts\IdValueInterface;
+use Infocyph\UID\IdComparator;
/**
* @template TParsed of array
*/
abstract readonly class AbstractParsedIdValue implements IdValueInterface
{
- use ComparableIdValue;
-
/** @var TParsed */
protected array $parsed;
+ private string $value;
+
public function __construct(string $value)
{
- $this->parsed = $this->initializeComparableValue(
- $value,
- $this->validator(),
- $this->parser(),
- $this->invalidMessage(),
- );
+ $validator = $this->validator();
+ $validator($value) || throw new \InvalidArgumentException($this->invalidMessage());
+
+ $parser = $this->parser();
+ $this->value = $value;
+ $this->parsed = $parser($value);
+ }
+
+ public function __toString(): string
+ {
+ return $this->toString();
}
abstract protected function invalidMessage(): string;
@@ -37,4 +43,26 @@ abstract protected function parser(): callable;
* @return callable(string):bool
*/
abstract protected function validator(): callable;
+
+ public function compare(IdValueInterface|string $other): int
+ {
+ $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other;
+
+ return IdComparator::compare($this->value, $otherValue);
+ }
+
+ public function getVersion(): ?int
+ {
+ return null;
+ }
+
+ public function isSortable(): bool
+ {
+ return true;
+ }
+
+ public function toString(): string
+ {
+ return $this->value;
+ }
}
diff --git a/src/Value/ComparableIdValue.php b/src/Value/ComparableIdValue.php
deleted file mode 100644
index adf3890..0000000
--- a/src/Value/ComparableIdValue.php
+++ /dev/null
@@ -1,58 +0,0 @@
-toString();
- }
-
- public function compare(IdValueInterface|string $other): int
- {
- $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other;
-
- return IdComparator::compare($this->value, $otherValue);
- }
-
- public function getVersion(): ?int
- {
- return null;
- }
-
- public function isSortable(): bool
- {
- return true;
- }
-
- public function toString(): string
- {
- return $this->value;
- }
-
- /**
- * @template T of array
- * @param callable(string):bool $validator
- * @param callable(string):T $parser
- * @return T
- */
- protected function initializeComparableValue(
- string $value,
- callable $validator,
- callable $parser,
- string $invalidMessage,
- ): array {
- $validator($value) || throw new \InvalidArgumentException($invalidMessage);
- $this->value = $value;
-
- return $parser($value);
- }
-}
diff --git a/src/Value/SonyflakeValue.php b/src/Value/SonyflakeValue.php
index a67b11b..ab8c43e 100644
--- a/src/Value/SonyflakeValue.php
+++ b/src/Value/SonyflakeValue.php
@@ -5,6 +5,7 @@
namespace Infocyph\UID\Value;
use DateTimeImmutable;
+use Infocyph\UID\Enums\SonyflakeFormat;
use Infocyph\UID\Sonyflake;
/**
@@ -12,11 +13,19 @@
*/
final readonly class SonyflakeValue extends AbstractParsedIdValue
{
- public function __construct(string $value, private ?int $customEpoch = null)
- {
+ public function __construct(
+ string $value,
+ private ?int $customEpoch = null,
+ private SonyflakeFormat $format = SonyflakeFormat::UID,
+ ) {
parent::__construct($value);
}
+ public function getFormat(): SonyflakeFormat
+ {
+ return $this->format;
+ }
+
public function getMachineId(): int
{
return $this->parsed['machine_id'];
@@ -35,8 +44,8 @@ protected function invalidMessage(): string
protected function parser(): callable
{
return $this->customEpoch === null
- ? Sonyflake::parse(...)
- : fn(string $id): array => Sonyflake::parseWithEpoch($id, $this->customEpoch);
+ ? fn(string $id): array => Sonyflake::parse($id, $this->format)
+ : fn(string $id): array => Sonyflake::parseWithEpoch($id, $this->customEpoch, $this->format);
}
protected function validator(): callable
diff --git a/src/XID.php b/src/XID.php
index 92830b0..f57d313 100644
--- a/src/XID.php
+++ b/src/XID.php
@@ -11,7 +11,7 @@
final class XID
{
- private const ALPHABET = '0123456789abcdefghijklmnopqrstuv';
+ private const string ALPHABET = '0123456789abcdefghijklmnopqrstuv';
private static ?int $counter = null;
diff --git a/tests/BaseEncoderTest.php b/tests/BaseEncoderTest.php
new file mode 100644
index 0000000..8104c67
--- /dev/null
+++ b/tests/BaseEncoderTest.php
@@ -0,0 +1,24 @@
+toBe($vector['sha256'])
+ ->and(BaseEncoder::decodeToBytes($encoded, $vector['base'], strlen($bytes)))->toBe($bytes);
+ }
+});
+
+test('radix encoding retains input bounds and supported alphabets', function (): void {
+ expect(fn(): string => BaseEncoder::encodeBytes('', 36))->toThrow(InvalidArgumentException::class)
+ ->and(fn(): string => BaseEncoder::encodeBytes(str_repeat("\0", 1025), 36))->toThrow(InvalidArgumentException::class)
+ ->and(fn(): string => BaseEncoder::encodeBytes("\x01", 37))->toThrow(InvalidArgumentException::class);
+});
diff --git a/tests/FilesystemSequenceV5Test.php b/tests/FilesystemSequenceV5Test.php
index b29a04c..e8dc94d 100644
--- a/tests/FilesystemSequenceV5Test.php
+++ b/tests/FilesystemSequenceV5Test.php
@@ -64,10 +64,9 @@
}
});
-test('filesystem reservation ranges never overlap across processes', function () {
- if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) {
- $this->markTestSkipped('The pcntl extension is required for multi-process coverage');
- }
+test('filesystem reservation ranges never overlap across processes', function (int $reservationSize) {
+ expect(function_exists('pcntl_fork'))->toBeTrue()
+ ->and(function_exists('pcntl_exec'))->toBeTrue();
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6));
mkdir($directory, 0700);
@@ -79,7 +78,7 @@
$pid = pcntl_fork();
expect($pid)->toBeGreaterThanOrEqual(0);
if ($pid === 0) {
- $provider = new FilesystemSequenceProvider($directory, 'shared', reservationSize: 16);
+ $provider = new FilesystemSequenceProvider($directory, 'shared', reservationSize: $reservationSize);
$allocations = [];
for ($index = 0; $index < 100; ++$index) {
$allocations[] = $provider->next('sequence', 1, 123456);
@@ -109,12 +108,11 @@
}
rmdir($directory);
}
-});
+})->with([1, 16]);
test('coordinated generators remain unique across processes', function (string $algorithm) {
- if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) {
- $this->markTestSkipped('The pcntl extension is required for multi-process coverage');
- }
+ expect(function_exists('pcntl_fork'))->toBeTrue()
+ ->and(function_exists('pcntl_exec'))->toBeTrue();
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6));
mkdir($directory, 0700);
diff --git a/tests/Fixtures/radix-vectors.json b/tests/Fixtures/radix-vectors.json
new file mode 100644
index 0000000..1edbd40
--- /dev/null
+++ b/tests/Fixtures/radix-vectors.json
@@ -0,0 +1,632 @@
+[
+ {
+ "hex": "00",
+ "base": 10,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00",
+ "base": 16,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00",
+ "base": 32,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00",
+ "base": 36,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00",
+ "base": 58,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "00",
+ "base": 62,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 10,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 16,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 32,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 36,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 58,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "00000000000000",
+ "base": 62,
+ "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+ },
+ {
+ "hex": "01",
+ "base": 10,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "01",
+ "base": 16,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "01",
+ "base": 32,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "01",
+ "base": 36,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "01",
+ "base": 58,
+ "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"
+ },
+ {
+ "hex": "01",
+ "base": 62,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "010203",
+ "base": 10,
+ "sha256": "24260b3c030c6e4f86175247e322724d3997a1310105fa003b9edab91b80b000"
+ },
+ {
+ "hex": "010203",
+ "base": 16,
+ "sha256": "91d29cb4702d2677c21abcbeefe7d75a9caff7761216ac71f8f6fb728a03cef7"
+ },
+ {
+ "hex": "010203",
+ "base": 32,
+ "sha256": "e39b8e0726aa4a8b86b3096a3ca42923255db5f50dc193b0ce9f64c641cda133"
+ },
+ {
+ "hex": "010203",
+ "base": 36,
+ "sha256": "db7116bec467527726501fc8a5b14ea6d94e304a0b6fdf4224e76ce0c7329b52"
+ },
+ {
+ "hex": "010203",
+ "base": 58,
+ "sha256": "c666dfb52eb204a33e933a44afdcaab0b6d23c1675bb2d2d651c8e2e09a5c4c1"
+ },
+ {
+ "hex": "010203",
+ "base": 62,
+ "sha256": "304bd1f15dba3af470b3de501f7a39c64551e431589a5403530d8622c6283209"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 10,
+ "sha256": "f807212b6748a8300fc3702322caa515edf0a6ed9bbc86e94c451e351b080c60"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 16,
+ "sha256": "a44ba123189855990795e3260a64b34cdae6b29bf1c941818a34cba8bbc45575"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 32,
+ "sha256": "edd5889f8f8ec599b6b440cf0073217786a62ddddcb06c540b71d1395a74f65b"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 36,
+ "sha256": "efce4cbc26a9225f27698923308bfcba97d727e33b42460817fd64eea9e63d96"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 58,
+ "sha256": "ed4eb404ddaf41bd8260aa633694def36054181d7205b0b25c30636ad7d33110"
+ },
+ {
+ "hex": "ffffffff",
+ "base": 62,
+ "sha256": "e084d93abbe254544ab1ca05a8a3becd511915ec22c6fab371d500fa72b07811"
+ },
+ {
+ "hex": "0100000000",
+ "base": 10,
+ "sha256": "6c1ca4002509bc24e93aa4211830738fce7e205e41364e11301ac5a0635cb4b5"
+ },
+ {
+ "hex": "0100000000",
+ "base": 16,
+ "sha256": "e59bbea6227c578f97fc467bc62dc3407d4885693d74e6e970f6cab44158fef4"
+ },
+ {
+ "hex": "0100000000",
+ "base": 32,
+ "sha256": "ace9f9389bff190c68b88f731056d0667d25438f8b5cb75b858b0ab6623fe08b"
+ },
+ {
+ "hex": "0100000000",
+ "base": 36,
+ "sha256": "3e06c89d3a704cb155bdb96a0194e267c391d56656bb17927cff8c7151d21504"
+ },
+ {
+ "hex": "0100000000",
+ "base": 58,
+ "sha256": "1d891934c5156b64b3ff9f4bbb75fb02a74948b9c87158d4b28925d2ac55817a"
+ },
+ {
+ "hex": "0100000000",
+ "base": 62,
+ "sha256": "6689137fa3ce8c223bb53873a4a4ebd20db18558ee3f928703ffd0ee4c508ff9"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 10,
+ "sha256": "2e979f5af74005579ea3ba3907f8de33d768f935115a03adc2cea6ba8cd62613"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 16,
+ "sha256": "54861dbd9763f64887854ea22c2de89435f5b1917f8079ad719f8990563ae919"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 32,
+ "sha256": "9727f344a8dbd01c4c06d0682b99921e77a917d8c86e9d102ac51ad272aeea90"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 36,
+ "sha256": "32cd4e0080086ea66c93db83cb2db931a6f1aae0f8559042a0f52699961c88a6"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 58,
+ "sha256": "c7c74041de89b891faa5d39dc26a7d61ce5a761faa16fedb8bebcefa5939a302"
+ },
+ {
+ "hex": "ffffffffffffff",
+ "base": 62,
+ "sha256": "6c0e8b7c084ce2b6b115632e058e5ea1417d5d1d1ccfe0f97aca11ae5e7a9856"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 10,
+ "sha256": "2cdb26265b4dc65e3b44d694f121fd6de99b9e4b8ae7f08d84bfa9537635ae43"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 16,
+ "sha256": "6534b338bcb91cf173444c24ed8bc0f1b7065face0ea95cdd1b936556c6860ed"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 32,
+ "sha256": "186e9c46e8ef522bab0c99889bb79855051fd248a1f21c8d6613bf48e1a7cb4d"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 36,
+ "sha256": "208d1995d8a68c9d6fb54c610f05d3fdbc7c6d1fc2c762c28134512afcf31004"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 58,
+ "sha256": "8e4deb0ebdaa4a5316d3c6c3cd5aad65f3b23551a4e3e7bd8fd562fe6e758608"
+ },
+ {
+ "hex": "ffffffffffffffff",
+ "base": 62,
+ "sha256": "751182822485df44ab62837e51046dae09dce275a529a350757c8eed4086284a"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 10,
+ "sha256": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 16,
+ "sha256": "139eb393675707818651f879828a526159209ca3ad3b2f94f9f8ec8c4fb5e610"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 32,
+ "sha256": "e251cf358c15b1e31a8102955a282c31a4a82b735076393aa79daad62d9eed04"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 36,
+ "sha256": "8dd8e3c360e2ce9f175e735c81f62f3d04db3541a4ea3b4468aca16d0f72142a"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 58,
+ "sha256": "9822cb8a94b821fb9a0c3559d5b8ae5970d5918d6203542412d8659fa0e47e66"
+ },
+ {
+ "hex": "010000000000000000",
+ "base": 62,
+ "sha256": "6587c44d3f1cfec0fdfd9bf257ddbc17811d3e2ebea8469f4e3a37e609755e78"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 10,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 16,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 32,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 36,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 58,
+ "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"
+ },
+ {
+ "hex": "000000000000000001",
+ "base": 62,
+ "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 10,
+ "sha256": "62f34becf1824be7bc2cd8bf3c74ce61a095a160a507818aef987bb29902972f"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 16,
+ "sha256": "79ad567e2df031ffca60b675bcbc52ef5d92e9acf36924aa85a0008860ff1219"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 32,
+ "sha256": "d996cf67728f15cdea403d1a95c72e5bf132b4f65f7603a8e5a0e27b427eff21"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 36,
+ "sha256": "9f2e8789ab4a1ecfaa3cbfd128cb41eb8f60f2cbac5afe39f5d91051f9e89597"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 58,
+ "sha256": "8817ec0e2858b0e01b19dbab30ff745b7effddb6411008260379ff24bd54c345"
+ },
+ {
+ "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "base": 62,
+ "sha256": "96dae209ef19bb45c0f0aa3e695323c0111d363a21499333ee04024de1b04fa5"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 10,
+ "sha256": "c7580a7344e36b87c5d8078a90b39aa93a2d6c5166083acddb12c1402a5038db"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 16,
+ "sha256": "d6be42c836477c05cd6d674c79c21cab7a381398b2e55f9bafaa1ad2573b564f"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 32,
+ "sha256": "949af726728ff4b2494bd9e4d7b0cd4a2dddeaefcc86298c6ea20dbcc5b3184a"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 36,
+ "sha256": "f8f98956a7547f3dcb96b7118e39f3ac54b47e3b453d6fb4c2546b32ddf079b6"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 58,
+ "sha256": "8a6b63d5ba101a9a5ed2a4a63478c215ab202763ab318af97d2a9e183be6b620"
+ },
+ {
+ "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "base": 62,
+ "sha256": "7e5371e1310f0fdcf424f3071fb88a731eb85e43401cb4e2a59804c27ae9ee99"
+ },
+ {
+ "hex": "01869f",
+ "base": 10,
+ "sha256": "fd5f56b40a79a385708428e7b32ab996a681080a166a2206e750eb4819186145"
+ },
+ {
+ "hex": "0186a0",
+ "base": 10,
+ "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1"
+ },
+ {
+ "hex": "0186a1",
+ "base": 10,
+ "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841"
+ },
+ {
+ "hex": "0f423f",
+ "base": 10,
+ "sha256": "937377f056160fc4b15e0b770c67136a5f03c15205b4d3bf918268fefa2c6d0a"
+ },
+ {
+ "hex": "0f4240",
+ "base": 10,
+ "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45"
+ },
+ {
+ "hex": "0f4241",
+ "base": 10,
+ "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0"
+ },
+ {
+ "hex": "3b9ac9ff",
+ "base": 10,
+ "sha256": "bb421fa35db885ce507b0ef5c3f23cb09c62eb378fae3641c165bdf4c0272949"
+ },
+ {
+ "hex": "3b9aca00",
+ "base": 10,
+ "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479"
+ },
+ {
+ "hex": "3b9aca01",
+ "base": 10,
+ "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c"
+ },
+ {
+ "hex": "0fffff",
+ "base": 16,
+ "sha256": "99834619b3c160248b69c7f42ba868f945a0ea04cd31cf2f60dc4bc8f7d13b8a"
+ },
+ {
+ "hex": "100000",
+ "base": 16,
+ "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1"
+ },
+ {
+ "hex": "100001",
+ "base": 16,
+ "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841"
+ },
+ {
+ "hex": "ffffff",
+ "base": 16,
+ "sha256": "e2dbf8f5c4cc151480213d21f95c72aa73a001bce4915b17691ae40952dcd793"
+ },
+ {
+ "hex": "01000000",
+ "base": 16,
+ "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45"
+ },
+ {
+ "hex": "01000001",
+ "base": 16,
+ "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0"
+ },
+ {
+ "hex": "0fffffffff",
+ "base": 16,
+ "sha256": "96c9766aa0d2590be49a4693e4919653a72537b662501f56dfa74404d06ccaef"
+ },
+ {
+ "hex": "1000000000",
+ "base": 16,
+ "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479"
+ },
+ {
+ "hex": "1000000001",
+ "base": 16,
+ "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c"
+ },
+ {
+ "hex": "01ffffff",
+ "base": 32,
+ "sha256": "f3fb5428392674c123adc9798d8482876663ded63ef0b4d7a65adc290e6ced3f"
+ },
+ {
+ "hex": "02000000",
+ "base": 32,
+ "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1"
+ },
+ {
+ "hex": "02000001",
+ "base": 32,
+ "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841"
+ },
+ {
+ "hex": "3fffffff",
+ "base": 32,
+ "sha256": "5e22363936e77b200d078a778a0eef2db904174476b41e4569df1344839ced8e"
+ },
+ {
+ "hex": "40000000",
+ "base": 32,
+ "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45"
+ },
+ {
+ "hex": "40000001",
+ "base": 32,
+ "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0"
+ },
+ {
+ "hex": "1fffffffffff",
+ "base": 32,
+ "sha256": "76ed0b82ee51b4e3df728afe66507924922757eebd6b33764b4e6b86d34a2a1e"
+ },
+ {
+ "hex": "200000000000",
+ "base": 32,
+ "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479"
+ },
+ {
+ "hex": "200000000001",
+ "base": 32,
+ "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c"
+ },
+ {
+ "hex": "039aa3ff",
+ "base": 36,
+ "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07"
+ },
+ {
+ "hex": "039aa400",
+ "base": 36,
+ "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1"
+ },
+ {
+ "hex": "039aa401",
+ "base": 36,
+ "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841"
+ },
+ {
+ "hex": "81bf0fff",
+ "base": 36,
+ "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605"
+ },
+ {
+ "hex": "81bf1000",
+ "base": 36,
+ "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45"
+ },
+ {
+ "hex": "81bf1001",
+ "base": 36,
+ "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0"
+ },
+ {
+ "hex": "5c5e4523ffff",
+ "base": 36,
+ "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033"
+ },
+ {
+ "hex": "5c5e45240000",
+ "base": 36,
+ "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479"
+ },
+ {
+ "hex": "5c5e45240001",
+ "base": 36,
+ "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c"
+ },
+ {
+ "hex": "271f359f",
+ "base": 58,
+ "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07"
+ },
+ {
+ "hex": "271f35a0",
+ "base": 58,
+ "sha256": "564374777639c82cf0c285c4b85704c0a19c0b8d008f25fc7ce4b560f031515c"
+ },
+ {
+ "hex": "271f35a1",
+ "base": 58,
+ "sha256": "c8b5462497d37eb053ccd8954c5fa4c5932303f64687cfb30ec7279fcf3309a8"
+ },
+ {
+ "hex": "08dd12263f",
+ "base": 58,
+ "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605"
+ },
+ {
+ "hex": "08dd122640",
+ "base": 58,
+ "sha256": "5c4be4f3632c63145070274c378fede7b99e82b6dc80a7927bb58d5f0373b529"
+ },
+ {
+ "hex": "08dd122641",
+ "base": 58,
+ "sha256": "66f4108f1134cabde7749f1a9be4aa62b317f07e2e5051116b307d9e7e9df37b"
+ },
+ {
+ "hex": "1a636a90b079ff",
+ "base": 58,
+ "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033"
+ },
+ {
+ "hex": "1a636a90b07a00",
+ "base": 58,
+ "sha256": "340076c5ad7c98d5f921b7fb87e03347ae1f012f89b3579f307ae827c52e2cf1"
+ },
+ {
+ "hex": "1a636a90b07a01",
+ "base": 58,
+ "sha256": "f46dda0b91be8402b3228b9bb52de3e45bbdbf70e9e4c1ff2d2bf740874d7f27"
+ },
+ {
+ "hex": "369b13df",
+ "base": 62,
+ "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07"
+ },
+ {
+ "hex": "369b13e0",
+ "base": 62,
+ "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1"
+ },
+ {
+ "hex": "369b13e1",
+ "base": 62,
+ "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841"
+ },
+ {
+ "hex": "0d398ed03f",
+ "base": 62,
+ "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605"
+ },
+ {
+ "hex": "0d398ed040",
+ "base": 62,
+ "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45"
+ },
+ {
+ "hex": "0d398ed041",
+ "base": 62,
+ "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0"
+ },
+ {
+ "hex": "3017e892e23dff",
+ "base": 62,
+ "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033"
+ },
+ {
+ "hex": "3017e892e23e00",
+ "base": 62,
+ "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479"
+ },
+ {
+ "hex": "3017e892e23e01",
+ "base": 62,
+ "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c"
+ }
+]
diff --git a/tests/ForkSafetyTest.php b/tests/ForkSafetyTest.php
index 0233587..d4b5f4f 100644
--- a/tests/ForkSafetyTest.php
+++ b/tests/ForkSafetyTest.php
@@ -9,9 +9,8 @@
use Infocyph\UID\XID;
test('process-local generator state is reseeded after a fork', function (Closure $generator) {
- if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) {
- $this->markTestSkipped('The pcntl extension is required for fork-safety coverage');
- }
+ expect(function_exists('pcntl_fork'))->toBeTrue()
+ ->and(function_exists('pcntl_exec'))->toBeTrue();
$generator();
$resultFile = sys_get_temp_dir() . '/uid-fork-' . bin2hex(random_bytes(12));
diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php
index 4c816e5..7525d7b 100644
--- a/tests/IdFactoryTest.php
+++ b/tests/IdFactoryTest.php
@@ -6,6 +6,7 @@
use Infocyph\UID\Configuration\SonyflakeConfig;
use Infocyph\UID\Configuration\TBSLConfig;
use Infocyph\UID\Configuration\RandflakeConfig;
+use Infocyph\UID\Enums\SonyflakeFormat;
use Infocyph\UID\Enums\UlidGenerationMode;
use Infocyph\UID\Id;
use Infocyph\UID\RandomId;
@@ -90,3 +91,14 @@
->and($tbsl)->toHaveLength(20)
->and($randflake)->toBeString();
});
+
+
+test('Sonyflake value preserves explicit format metadata', function (): void {
+ $value = Id::sonyflakeValue(new SonyflakeConfig(
+ machineId: 42,
+ format: SonyflakeFormat::UPSTREAM,
+ ));
+
+ expect($value->getFormat())->toBe(SonyflakeFormat::UPSTREAM)
+ ->and($value->getMachineId())->toBe(42);
+});
diff --git a/tests/PersistentDomainLimitTest.php b/tests/PersistentDomainLimitTest.php
new file mode 100644
index 0000000..b6db78a
--- /dev/null
+++ b/tests/PersistentDomainLimitTest.php
@@ -0,0 +1,99 @@
+ Snowflake::generateWithConfig(new SnowflakeConfig(
+ customEpoch: 1_700_000_002_000,
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ )))->toThrow(SnowflakeException::class, 'domain limit exceeded');
+});
+
+test('Sonyflake safety state fails closed instead of evicting live domains', function (): void {
+ $provider = statelessDomainProvider();
+ $runtime = new GenerationContext(clock: new PersistentDomainClock());
+
+ for ($machineId = 0; $machineId < 1024; ++$machineId) {
+ Sonyflake::generateWithConfig(new SonyflakeConfig(
+ machineId: $machineId,
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ ));
+ }
+
+ expect(fn(): string => Sonyflake::generateWithConfig(new SonyflakeConfig(
+ machineId: 1024,
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ )))->toThrow(SonyflakeException::class, 'domain limit exceeded');
+});
+
+test('Randflake safety state fails closed instead of evicting live domains', function (): void {
+ $provider = statelessDomainProvider();
+ $runtime = new GenerationContext(clock: new PersistentDomainClock());
+ $secret = '0123456789abcdef';
+
+ for ($nodeId = 0; $nodeId < 1024; ++$nodeId) {
+ Randflake::generateWithConfig(new RandflakeConfig(
+ nodeId: $nodeId,
+ leaseStart: 1_799_999_999,
+ leaseEnd: 1_800_000_001,
+ secret: $secret,
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ ));
+ }
+
+ expect(fn(): string => Randflake::generateWithConfig(new RandflakeConfig(
+ nodeId: 1024,
+ leaseStart: 1_799_999_999,
+ leaseEnd: 1_800_000_001,
+ secret: $secret,
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ )))->toThrow(RandflakeException::class, 'domain limit exceeded');
+});
diff --git a/tests/RandflakeUpstreamFormatTest.php b/tests/RandflakeUpstreamFormatTest.php
new file mode 100644
index 0000000..029c038
--- /dev/null
+++ b/tests/RandflakeUpstreamFormatTest.php
@@ -0,0 +1,64 @@
+toBe('2111581968557607991')
+ ->and(Randflake::encodeString($id, RandflakeFormat::UPSTREAM))->toBe('1qjeojjevu31n')
+ ->and(Randflake::decodeString('1qjeojjevu31n', RandflakeFormat::UPSTREAM))->toBe($id)
+ ->and(Randflake::inspect($id, $secret, RandflakeFormat::UPSTREAM))->toBe([
+ 'timestamp' => 1_730_000_001,
+ 'node_id' => 0,
+ 'sequence' => 0,
+ ]);
+});
+
+test('Randflake upstream codec preserves signed vectors', function (): void {
+ expect(Randflake::decodeString('fphhelk04q8f8', RandflakeFormat::UPSTREAM))
+ ->toBe('-232447010193727000')
+ ->and(Randflake::encodeString('-232447010193727000', RandflakeFormat::UPSTREAM))
+ ->toBe('fphhelk04q8f8');
+});
+
+test('Randflake legacy format remains the default', function (): void {
+ $secret = '0123456789abcdef';
+ $id = Randflake::generate(
+ nodeId: 7,
+ leaseStart: time() - 1,
+ leaseEnd: time() + 5,
+ secret: $secret,
+ );
+
+ expect(Randflake::isValid($id))->toBeTrue()
+ ->and(Randflake::encodeString(Randflake::decodeString(Randflake::encodeString($id))))->toBe(
+ Randflake::encodeString($id),
+ );
+});
diff --git a/tests/RandflakeUpstreamPrimitiveTest.php b/tests/RandflakeUpstreamPrimitiveTest.php
new file mode 100644
index 0000000..15bfeb0
--- /dev/null
+++ b/tests/RandflakeUpstreamPrimitiveTest.php
@@ -0,0 +1,28 @@
+toBe($value);
+ }
+});
+
+test('SPARX64 encryption round trips fixed blocks', function (): void {
+ $cipher = new Sparx64(hex2bin('000102030405060708090a0b0c0d0e0f'));
+ $plain = hex2bin('0011223344556677');
+ $encrypted = $cipher->encrypt($plain);
+
+ expect($encrypted)->not()->toBe($plain)
+ ->and($cipher->decrypt($encrypted))->toBe($plain);
+});
diff --git a/tests/ReleaseBoundaryTest.php b/tests/ReleaseBoundaryTest.php
new file mode 100644
index 0000000..868ff76
--- /dev/null
+++ b/tests/ReleaseBoundaryTest.php
@@ -0,0 +1,182 @@
+time;
+ }
+}
+
+test('bound filesystem providers reject cancellation before fresh and reserved allocation', function (int $reservationSize): void {
+ $directory = sys_get_temp_dir() . '/uid-boundary-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $host = RuntimeContext::standalone();
+ $request = RequestContext::create($host);
+ $provider = new FilesystemSequenceProvider(
+ $directory,
+ reservationSize: $reservationSize,
+ runtime: new GenerationContext(runwire: new RunwireBinding($host, $request)),
+ );
+ $path = $directory . '/uid-test-1.seq';
+
+ try {
+ expect($provider->next('test', 1, 100))->toBe(1);
+ $before = file_get_contents($path);
+ $request->cancel(CancellationReason::HOST_CANCELLED);
+ expect(fn(): int => $provider->next('test', 1, 100))->toThrow(CancelledException::class)
+ ->and(file_get_contents($path))->toBe($before);
+ } finally {
+ unlink($path);
+ rmdir($directory);
+ }
+})->with([1, 8]);
+
+test('TBSL clocks remain independent across provider and machine domains', function (): void {
+ $lateClock = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1800000000')));
+ $earlyClock = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000')));
+ $provider = new InMemorySequenceProvider();
+ TBSL::generateWithConfig(new TBSLConfig(sequenceProvider: $provider, runtime: $lateClock));
+
+ foreach ([
+ new TBSLConfig(sequenceProvider: new InMemorySequenceProvider(), runtime: $earlyClock, clockBackwardPolicy: ClockBackwardPolicy::THROW),
+ new TBSLConfig(machineId: 1, sequenceProvider: $provider, runtime: $earlyClock, clockBackwardPolicy: ClockBackwardPolicy::THROW),
+ ] as $config) {
+ $id = TBSL::generateWithConfig($config);
+ expect(TBSL::parse($id)['time']->getTimestamp())->toBe(1_700_000_000);
+ }
+
+ expect(fn(): string => TBSL::generateWithConfig(new TBSLConfig(
+ sequenceProvider: $provider,
+ runtime: $earlyClock,
+ clockBackwardPolicy: ClockBackwardPolicy::THROW,
+ )))->toThrow(UIDException::class, 'Clock moved backwards');
+});
+
+test('Sonyflake rejects future epochs inside one clock tick', function (SonyflakeFormat $format, int $offset): void {
+ $provider = new InMemorySequenceProvider();
+ $config = new SonyflakeConfig(
+ customEpoch: 1_700_000_000_000 + $offset,
+ sequenceProvider: $provider,
+ runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000'))),
+ format: $format,
+ );
+
+ expect(fn(): string => Sonyflake::generateWithConfig($config))
+ ->toThrow(SonyflakeException::class, 'epoch must not be in the future');
+})->with([SonyflakeFormat::UID, SonyflakeFormat::UPSTREAM])->with([1, 9]);
+
+test('TBSL preserves eleven-digit seconds within its 60-bit timestamp field', function (): void {
+ $runtime = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@10000000000.123456')));
+ $id = TBSL::generateWithConfig(new TBSLConfig(machineId: 99, sequenceProvider: new InMemorySequenceProvider(), runtime: $runtime));
+ $parsed = TBSL::parse($id);
+ expect($parsed['time']->format('U.u'))->toBe('10000000000.123456')
+ ->and($parsed['machineId'])->toBe(99);
+});
+
+test('generation rejects out-of-domain clocks and keeps large wait budgets integer safe', function (): void {
+ $negative = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('1969-12-31T23:59:59.500000Z')));
+ expect(fn(): int => $negative->nowMicroseconds())->toThrow(InvalidArgumentException::class)
+ ->and((new GenerationContext(waitTimeoutMicros: PHP_INT_MAX))->waitDeadlineNanoseconds())->toBeInt();
+});
+
+test('upstream Randflake lease conversion fails with a domain error at integer exhaustion', function (): void {
+ $config = new \Infocyph\UID\Configuration\RandflakeConfig(
+ 0, 1_730_000_000, PHP_INT_MAX, '0123456789abcdef',
+ format: \Infocyph\UID\Enums\RandflakeFormat::UPSTREAM,
+ );
+ expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config))
+ ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class, 'exclusive boundary');
+});
+
+test('Randflake rechecks live domain state after reentrant provider work', function (\Infocyph\UID\Enums\RandflakeFormat $format): void {
+ $nested = false;
+ $config = null;
+ $nestedId = null;
+ $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider(
+ function (string $type, int $machineId, int $timestamp) use (&$nested, &$config, &$nestedId): int {
+ unset($type, $machineId, $timestamp);
+ if (!$nested) {
+ $nested = true;
+ $nestedId = \Infocyph\UID\Randflake::generateWithConfig($config);
+ }
+
+ return 1;
+ },
+ );
+ $config = new \Infocyph\UID\Configuration\RandflakeConfig(
+ 0, 1_730_000_000, 1_730_000_005, '0123456789abcdef',
+ sequenceProvider: $provider,
+ runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1730000001'))),
+ format: $format,
+ );
+
+ expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config))
+ ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class, 'allocation regressed')
+ ->and($nestedId)->toBeString();
+})->with([\Infocyph\UID\Enums\RandflakeFormat::UID, \Infocyph\UID\Enums\RandflakeFormat::UPSTREAM]);
+
+test('UUID node arguments reject trailing line breaks', function (): void {
+ foreach (['v1', 'v6', 'v8'] as $method) {
+ expect(fn(): string => \Infocyph\UID\UUID::$method("0123456789ab\n"))
+ ->toThrow(\Infocyph\UID\Exceptions\UUIDException::class);
+ }
+});
+
+test('implicit ULID and UUID generation fail promptly at terminal timestamp exhaustion', function (): void {
+ foreach ([
+ [\Infocyph\UID\ULID::class, 'waitForNextMillisecond', \Infocyph\UID\Exceptions\ULIDException::class],
+ [\Infocyph\UID\UUID::class, 'nextV7Timestamp', \Infocyph\UID\Exceptions\UUIDException::class],
+ ] as [$class, $method, $exception]) {
+ $wait = new ReflectionMethod($class, $method);
+ expect(fn(): int => $wait->invoke(null, 281_474_976_710_655))->toThrow($exception, 'exhausted');
+ }
+});
+
+test('Randflake rejects injected times before its epoch without consuming an allocation', function (): void {
+ $calls = 0;
+ $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider(function (string $type, int $machineId, int $timestamp) use (&$calls): int {
+ unset($type, $machineId, $timestamp);
+ return ++$calls;
+ });
+ $config = new \Infocyph\UID\Configuration\RandflakeConfig(
+ 0, 0, 1_730_000_005, '0123456789abcdef',
+ sequenceProvider: $provider,
+ runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1729999999'))),
+ );
+ expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config))
+ ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class)
+ ->and($calls)->toBe(0);
+});
+
+test('Sonyflake rejects extreme epochs with a domain error before integer division', function (): void {
+ $config = new SonyflakeConfig(
+ customEpoch: PHP_INT_MIN,
+ sequenceProvider: new InMemorySequenceProvider(),
+ runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000'))),
+ );
+ expect(fn(): string => Sonyflake::generateWithConfig($config))
+ ->toThrow(SonyflakeException::class, 'maximum life cycle');
+});
diff --git a/tests/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php
new file mode 100644
index 0000000..7cb92e4
--- /dev/null
+++ b/tests/RuntimeIntegrationTest.php
@@ -0,0 +1,299 @@
+time;
+ }
+}
+
+final class OverflowUidSequenceProvider implements SequenceProviderInterface
+{
+ public function next(string $type, int $machineId, int $timestamp): int
+ {
+ unset($type, $machineId, $timestamp);
+
+ return 4_097;
+ }
+}
+
+test('frozen injected clocks exhaust the bounded generation wait', function (): void {
+ $runtime = new GenerationContext(
+ clock: new FrozenUidClock(new DateTimeImmutable('2026-10-06T15:00:00.000000+00:00')),
+ waitTimeoutMicros: 2_000,
+ );
+ $config = new SnowflakeConfig(
+ sequenceProvider: new OverflowUidSequenceProvider(),
+ runtime: $runtime,
+ );
+
+ expect(fn(): string => Snowflake::generateWithConfig($config))
+ ->toThrow(SnowflakeException::class, 'Timed out waiting for a valid Snowflake timestamp');
+});
+
+test('Runwire cancellation stops generation before allocation', function (): void {
+ $host = RuntimeContext::standalone();
+ $request = RequestContext::create($host);
+ $binding = new RunwireBinding($host, $request);
+ $runtime = new GenerationContext(runwire: $binding);
+ $provider = new InMemorySequenceProvider();
+
+ $request->cancel(CancellationReason::HOST_CANCELLED);
+
+ expect(fn(): string => Snowflake::generateWithConfig(new SnowflakeConfig(
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ )))->toThrow(CancelledException::class)
+ ->and($provider->next('probe', 0, 1))->toBe(1);
+});
+
+test('native generation remains available without optional runtime binding', function (): void {
+ expect(Snowflake::isValid(Snowflake::generateWithConfig(new SnowflakeConfig(
+ sequenceProvider: new InMemorySequenceProvider(),
+ ))))->toBeTrue();
+});
+
+
+test('Runwire binding rejects a request from another runtime', function (): void {
+ $left = RuntimeContext::standalone();
+ $right = RuntimeContext::standalone();
+ $request = RequestContext::create($left);
+
+ expect(fn(): RunwireBinding => new RunwireBinding($right, $request))
+ ->toThrow(LogicException::class, 'different runtime context');
+});
+
+test('Runwire binding rejects completed requests', function (): void {
+ $host = RuntimeContext::standalone();
+ $request = RequestContext::create($host);
+ $request->complete();
+
+ expect(fn(): RunwireBinding => new RunwireBinding($host, $request))
+ ->toThrow(LogicException::class, 'already completed');
+});
+
+test('Runwire binding preserves exact host instances and cooperative scope waits', function (): void {
+ $capabilities = new RuntimeCapabilities(
+ driver: RuntimeDriver::NATIVE,
+ runwireLoopAvailable: true,
+ supportsRunwireCoroutines: true,
+ );
+ $host = RuntimeContext::fromCapabilities($capabilities, 'uid-test', concurrent: true);
+ $request = RequestContext::create($host);
+ $coroutines = new CoroutineRuntime();
+
+ $result = $coroutines->runRequest(
+ $request,
+ function (CoroutineScope $scope) use ($host, $request): array {
+ $binding = new RunwireBinding($host, $request, $scope);
+ $runtime = new GenerationContext(runwire: $binding, waitTimeoutMicros: 10_000);
+ $runtime->sleepMicroseconds(100);
+
+ return [
+ $binding->runtime === $host,
+ $binding->request === $request,
+ $binding->scope === $scope,
+ ];
+ },
+ );
+
+ expect($result)->toBe([true, true, true])
+ ->and($request->completed())->toBeFalse();
+});
+
+test('a cancellation after allocation never recycles the consumed allocation', function (): void {
+ $host = RuntimeContext::standalone();
+ $request = RequestContext::create($host);
+ $runtime = new GenerationContext(runwire: new RunwireBinding($host, $request));
+ $allocations = 0;
+ $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider(
+ static function (string $type, int $machineId, int $timestamp) use (&$allocations, $request): int {
+ unset($type, $machineId, $timestamp);
+ ++$allocations;
+ $request->cancel(CancellationReason::HOST_CANCELLED);
+
+ return $allocations;
+ },
+ );
+
+ Snowflake::generateWithConfig(new SnowflakeConfig(
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ ));
+
+ expect($allocations)->toBe(1)
+ ->and(fn(): string => Snowflake::generateWithConfig(new SnowflakeConfig(
+ sequenceProvider: $provider,
+ runtime: $runtime,
+ )))->toThrow(CancelledException::class)
+ ->and($allocations)->toBe(1);
+});
+
+
+function forwardUidSnowflake(SnowflakeConfig $config): string
+{
+ return Snowflake::generateWithConfig($config);
+}
+
+test('Runwire bindings survive intermediary config forwarding without rediscovery', function (): void {
+ $host = RuntimeContext::standalone();
+ $request = RequestContext::create($host);
+ $binding = new RunwireBinding($host, $request);
+ $config = new SnowflakeConfig(
+ sequenceProvider: new InMemorySequenceProvider(),
+ runtime: new GenerationContext(runwire: $binding),
+ );
+
+ expect(forwardUidSnowflake($config))->toBeString()
+ ->and($config->runtime?->runwire)->toBe($binding)
+ ->and($binding->runtime)->toBe($host)
+ ->and($binding->request)->toBe($request);
+});
+
+test('Runwire bindings reject scopes after the host closes them', function (): void {
+ $capabilities = new RuntimeCapabilities(
+ driver: RuntimeDriver::NATIVE,
+ runwireLoopAvailable: true,
+ supportsRunwireCoroutines: true,
+ );
+ $host = RuntimeContext::fromCapabilities($capabilities, 'uid-closed-scope', concurrent: true);
+ $request = RequestContext::create($host);
+ $coroutines = new CoroutineRuntime();
+ $capturedScope = null;
+
+ $coroutines->runRequest(
+ $request,
+ function (CoroutineScope $scope) use (&$capturedScope): void {
+ $capturedScope = $scope;
+ },
+ );
+
+ expect($capturedScope)->toBeInstanceOf(CoroutineScope::class)
+ ->and(fn(): RunwireBinding => new RunwireBinding($host, $request, $capturedScope))
+ ->toThrow(LogicException::class, 'already closed');
+});
+
+test('contended locks suspend cooperatively and stop on host cancellation', function (bool $cancel): void {
+ $directory = sys_get_temp_dir() . '/uid-cooperative-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $path = $directory . '/uid-snowflake-0.seq';
+ file_put_contents($path, '1700000000000,7');
+ $held = fopen($path, 'r+b');
+ expect(flock($held, LOCK_EX))->toBeTrue();
+ $host = RuntimeContext::fromCapabilities(new RuntimeCapabilities(
+ driver: RuntimeDriver::NATIVE,
+ runwireLoopAvailable: true,
+ supportsRunwireCoroutines: true,
+ ), 'uid-contended', concurrent: true);
+ $request = RequestContext::create($host);
+ $coroutines = new CoroutineRuntime();
+ $ranOtherTask = false;
+
+ $allocate = function () use ($coroutines, $request, $host, $held, $directory, $cancel, &$ranOtherTask): string {
+ return $coroutines->runRequest($request, function (CoroutineScope $scope) use ($request, $host, $held, $directory, $cancel, &$ranOtherTask): string {
+ $scope->spawn(function () use ($scope, $request, $held, $cancel, &$ranOtherTask): void {
+ $scope->sleep(0.005);
+ $ranOtherTask = true;
+ if ($cancel) {
+ $request->cancel(CancellationReason::HOST_CANCELLED);
+ }
+ flock($held, LOCK_UN);
+ });
+ // A worker-owned provider receives each request's binding through the config.
+ $provider = new \Infocyph\UID\Sequence\FilesystemSequenceProvider($directory);
+
+ return forwardUidSnowflake(new SnowflakeConfig(
+ sequenceProvider: $provider,
+ runtime: new GenerationContext(
+ clock: new FrozenUidClock(new DateTimeImmutable('@1700000000')),
+ runwire: new RunwireBinding($host, $request, $scope),
+ waitTimeoutMicros: 100_000,
+ ),
+ ));
+ });
+ };
+
+ try {
+ if ($cancel) {
+ expect($allocate)->toThrow(CancelledException::class)
+ ->and(file_get_contents($path))->toBe('1700000000000,7');
+ } else {
+ expect(Snowflake::parse($allocate())['sequence'])->toBe(7)
+ ->and(file_get_contents($path))->toBe('1700000000000,8')
+ ->and($request->completed())->toBeFalse();
+ }
+ expect($ranOtherTask)->toBeTrue();
+ } finally {
+ flock($held, LOCK_UN);
+ fclose($held);
+ unlink($path);
+ rmdir($directory);
+ }
+})->with([false, true]);
+
+test('lock timeout forbids allocation after a late cooperative wake', function (): void {
+ $directory = sys_get_temp_dir() . '/uid-late-wake-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $path = $directory . '/uid-test-1.seq';
+ file_put_contents($path, '100,1');
+ $held = fopen($path, 'r+b');
+ expect(flock($held, LOCK_EX))->toBeTrue();
+ $host = RuntimeContext::fromCapabilities(new RuntimeCapabilities(
+ driver: RuntimeDriver::NATIVE,
+ runwireLoopAvailable: true,
+ supportsRunwireCoroutines: true,
+ ), 'uid-late-wake', concurrent: true);
+ $request = RequestContext::create($host);
+ $coroutines = new CoroutineRuntime();
+
+ try {
+ $failure = $coroutines->runRequest($request, function (CoroutineScope $scope) use ($host, $request, $held, $directory): ?\Infocyph\UID\Exceptions\FileLockException {
+ $scope->spawn(function () use ($held): void {
+ // Simulate host work delaying the allocator's scheduled wake.
+ usleep(10_000);
+ flock($held, LOCK_UN);
+ });
+ $provider = new \Infocyph\UID\Sequence\FilesystemSequenceProvider($directory);
+ try {
+ $provider->next('test', 1, 100, new GenerationContext(
+ runwire: new RunwireBinding($host, $request, $scope),
+ waitTimeoutMicros: 5_000,
+ ));
+ } catch (\Infocyph\UID\Exceptions\FileLockException $exception) {
+ return $exception;
+ }
+
+ return null;
+ });
+ expect($failure)->toBeInstanceOf(\Infocyph\UID\Exceptions\FileLockException::class)
+ ->and(file_get_contents($path))->toBe('100,1')
+ ->and($request->completed())->toBeFalse();
+ } finally {
+ flock($held, LOCK_UN);
+ fclose($held);
+ unlink($path);
+ rmdir($directory);
+ }
+});
diff --git a/tests/SequenceDomainLimitTest.php b/tests/SequenceDomainLimitTest.php
new file mode 100644
index 0000000..b9f5994
--- /dev/null
+++ b/tests/SequenceDomainLimitTest.php
@@ -0,0 +1,91 @@
+ */
+ private array $values = [];
+
+ public function get(string $key, mixed $default = null): mixed
+ {
+ return $this->values[$key] ?? $default;
+ }
+
+ public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
+ {
+ unset($ttl);
+ $this->values[$key] = $value;
+
+ return true;
+ }
+
+ public function delete(string $key): bool
+ {
+ unset($this->values[$key]);
+
+ return true;
+ }
+
+ public function clear(): bool
+ {
+ $this->values = [];
+
+ return true;
+ }
+
+ public function getMultiple(iterable $keys, mixed $default = null): iterable
+ {
+ foreach ($keys as $key) {
+ yield $key => $this->get($key, $default);
+ }
+ }
+
+ public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
+ {
+ foreach ($values as $key => $value) {
+ $this->set((string) $key, $value, $ttl);
+ }
+
+ return true;
+ }
+
+ public function deleteMultiple(iterable $keys): bool
+ {
+ foreach ($keys as $key) {
+ $this->delete((string) $key);
+ }
+
+ return true;
+ }
+
+ public function has(string $key): bool
+ {
+ return array_key_exists($key, $this->values);
+ }
+}
+
+test('in-memory sequence state fails closed at the live-domain bound', function (): void {
+ $provider = new InMemorySequenceProvider();
+ for ($domain = 0; $domain < 1024; ++$domain) {
+ expect($provider->next('domain-' . $domain, 0, 1))->toBe(1);
+ }
+
+ expect(fn(): int => $provider->next('domain-overflow', 0, 1))
+ ->toThrow(FileLockException::class, 'domain limit exceeded');
+});
+
+test('PSR-16 observed safety state fails closed at the live-domain bound', function (): void {
+ $provider = new PsrSimpleCacheSequenceProvider(new DomainLimitCache());
+ for ($domain = 0; $domain < 1024; ++$domain) {
+ expect($provider->next('d' . $domain, 0, 1))->toBe(1);
+ }
+
+ expect(fn(): int => $provider->next('overflow', 0, 1))
+ ->toThrow(FileLockException::class, 'domain limit exceeded');
+});
diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php
index 5a03d6c..d286dff 100644
--- a/tests/SequenceProviderTest.php
+++ b/tests/SequenceProviderTest.php
@@ -11,6 +11,8 @@
final class SequenceTestCache implements CacheInterface
{
+ public ?Closure $beforeRead = null;
+
public bool $failWrites = false;
public null|int|DateInterval $lastTtl = null;
@@ -43,6 +45,8 @@ public function deleteMultiple(iterable $keys): bool
public function get(string $key, mixed $default = null): mixed
{
+ ($this->beforeRead ?? static function (): void {})();
+
return $this->store[$key] ?? $default;
}
@@ -90,6 +94,36 @@ public function setMultiple(iterable $values, null|int|DateInterval $ttl = null)
}
}
+test('bound cache providers reject cancellation before writes and inside synchronizers', function (string $phase): void {
+ $host = \Infocyph\Runwire\RuntimeContext::standalone();
+ $request = \Infocyph\Runwire\RequestContext::create($host);
+ $runtime = new \Infocyph\UID\Runtime\GenerationContext(
+ runwire: new \Infocyph\UID\Runtime\RunwireBinding($host, $request),
+ );
+ $cache = new SequenceTestCache();
+ $cancel = static fn() => $request->cancel(\Infocyph\Runwire\Runtime\Enum\CancellationReason::HOST_CANCELLED);
+ $synchronizer = static function (string $key, callable $allocate) use ($cancel, $phase): int {
+ unset($key);
+ if ($phase === 'synchronizer') {
+ $cancel();
+ }
+
+ return $allocate();
+ };
+ $provider = new PsrSimpleCacheSequenceProvider($cache, synchronizer: $synchronizer, runtime: $runtime);
+
+ if ($phase === 'entry') {
+ $cancel();
+ } elseif ($phase === 'read') {
+ $cache->beforeRead = $cancel;
+ }
+
+ expect(fn(): int => $provider->next('test', 1, 100))
+ ->toThrow(\Infocyph\Runwire\Exception\CancelledException::class);
+ $cache->beforeRead = null;
+ expect($cache->has('uid.seq.test.1'))->toBeFalse();
+})->with(['entry', 'read', 'synchronizer']);
+
final class FutureOnceSequenceProvider implements SequenceProviderInterface
{
public int $calls = 0;
@@ -224,3 +258,27 @@ public function next(string $type, int $machineId, int $timestamp): int
}
}
});
+
+
+test('psr-16 sequence provider fails closed after observed state loss', function () {
+ $cache = new SequenceTestCache();
+ $provider = new PsrSimpleCacheSequenceProvider($cache);
+ expect($provider->next('snowflake', 1, 100))->toBe(1);
+ $cache->clear();
+
+ expect(fn(): int => $provider->next('snowflake', 1, 100))
+ ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class);
+});
+
+test('custom epochs are normalized at configuration construction', function () {
+ $epoch = new DateTime('2024-01-01T00:00:00+00:00');
+ $snowflake = new \Infocyph\UID\Configuration\SnowflakeConfig(customEpoch: $epoch);
+ $sonyflake = new \Infocyph\UID\Configuration\SonyflakeConfig(customEpoch: $epoch);
+ $snowflakeEpoch = $snowflake->resolveCustomEpochMs();
+ $sonyflakeEpoch = $sonyflake->resolveCustomEpochMs();
+
+ $epoch->modify('+1 day');
+
+ expect($snowflake->resolveCustomEpochMs())->toBe($snowflakeEpoch)
+ ->and($sonyflake->resolveCustomEpochMs())->toBe($sonyflakeEpoch);
+});
diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php
new file mode 100644
index 0000000..9d09376
--- /dev/null
+++ b/tests/SequenceSafetyTest.php
@@ -0,0 +1,179 @@
+toBeTrue();
+ $directory = sys_get_temp_dir() . '/uid-lock-race-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $path = $directory . '/state';
+ $target = $directory . '/target';
+ file_put_contents($path, '100,1');
+ file_put_contents($target, 'unchanged');
+ $handle = fopen($path, 'r+b');
+ $before = null;
+ $verify = Closure::bind(
+ static function () use ($path, $handle, &$before) {
+ return FileLock::verifyHandle($path, $handle, $before, 'replaced lock', function_exists('posix_geteuid') ? posix_geteuid() : null);
+ },
+ null,
+ FileLock::class,
+ );
+ expect($verify)->toBeInstanceOf(Closure::class);
+ // Prime PHP's path cache after loading the verifier and assertion machinery.
+ $before = lstat($path);
+ $pid = pcntl_fork();
+ if ($pid < 0) {
+ throw new RuntimeException('Unable to fork pathname replacement fixture');
+ }
+ if ($pid === 0) {
+ rename($path, $path . '.original');
+ symlink($target, $path);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ try {
+ $failure = null;
+ try {
+ $verify();
+ } catch (Throwable $exception) {
+ $failure = $exception;
+ }
+ expect($failure)->toBeInstanceOf(FileLockException::class)
+ ->and(file_get_contents($target))->toBe('unchanged');
+ } finally {
+ if (is_resource($handle)) {
+ fclose($handle);
+ }
+ unlink($path);
+ unlink($path . '.original');
+ unlink($target);
+ rmdir($directory);
+ }
+});
+
+test('filesystem sequence rejects symlink state without touching its target', function (): void {
+ if (PHP_OS_FAMILY === 'Windows') {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-safety-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $target = $directory . DIRECTORY_SEPARATOR . 'target';
+ $link = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq';
+ file_put_contents($target, 'unchanged');
+ symlink($target, $link);
+
+ try {
+ $provider = new FilesystemSequenceProvider($directory);
+ expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class)
+ ->and(file_get_contents($target))->toBe('unchanged');
+ } finally {
+ if (is_link($link)) {
+ unlink($link);
+ }
+ if (is_file($target)) {
+ unlink($target);
+ }
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+});
+
+test('cached parent resolution cannot allocate through an externally replaced directory link', function (): void {
+ expect(function_exists('pcntl_fork'))->toBeTrue();
+ $directory = sys_get_temp_dir() . '/uid-parent-race-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ mkdir($directory . '/first', 0700);
+ mkdir($directory . '/second', 0700);
+ $first = $directory . '/first/uid-test-1.seq';
+ $second = $directory . '/second/uid-test-1.seq';
+ $alias = $directory . '/current';
+ $path = $alias . '/uid-test-1.seq';
+ file_put_contents($first, '100,1');
+ file_put_contents($second, '100,100');
+ symlink($directory . '/first', $alias);
+ $provider = new FilesystemSequenceProvider($alias);
+ expect(realpath($path))->toBe($first);
+ lstat($path);
+
+ $pid = pcntl_fork();
+ if ($pid < 0) {
+ throw new RuntimeException('Unable to fork directory replacement fixture');
+ }
+ if ($pid === 0) {
+ unlink($alias);
+ symlink($directory . '/second', $alias);
+ exit(0);
+ }
+ pcntl_waitpid($pid, $status);
+
+ try {
+ try {
+ expect($provider->next('test', 1, 100))->toBe(101);
+ } catch (FileLockException) {
+ expect(file_get_contents($second))->toBe('100,100');
+ }
+ expect(file_get_contents($first))->toBe('100,1');
+ } finally {
+ unlink($alias);
+ unlink($first);
+ unlink($second);
+ rmdir($directory . '/first');
+ rmdir($directory . '/second');
+ rmdir($directory);
+ }
+});
+
+test('filesystem sequence fails closed at integer exhaustion', function (): void {
+ $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-safety-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $state = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq';
+ file_put_contents($state, '100,' . PHP_INT_MAX);
+
+ try {
+ $provider = new FilesystemSequenceProvider($directory);
+ expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class)
+ ->and(file_get_contents($state))->toBe('100,' . PHP_INT_MAX);
+ } finally {
+ if (is_file($state)) {
+ unlink($state);
+ }
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+});
+
+test('filesystem sequence rejects noncanonical and overflowing state without rewriting it', function (string $contents): void {
+ $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-state-' . bin2hex(random_bytes(6));
+ mkdir($directory, 0700);
+ $state = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq';
+ file_put_contents($state, $contents);
+
+ try {
+ $provider = new FilesystemSequenceProvider($directory);
+ expect(fn(): int => $provider->next('test', 1, PHP_INT_MAX))->toThrow(FileLockException::class)
+ ->and(file_get_contents($state))->toBe($contents);
+ } finally {
+ unlink($state);
+ rmdir($directory);
+ }
+})->with([
+ 'timestamp overflow' => '9223372036854775808,0',
+ 'allocation overflow' => '0,9223372036854775808',
+ 'timestamp leading zero' => '01,1',
+ 'allocation leading zero' => '1,01',
+ 'signed allocation' => '1,+1',
+ 'trailing newline' => "1,1\n",
+ 'extra field' => '1,1,1',
+ 'empty field' => '1,',
+]);
diff --git a/tests/SonyflakeFormatTest.php b/tests/SonyflakeFormatTest.php
new file mode 100644
index 0000000..ffc8175
--- /dev/null
+++ b/tests/SonyflakeFormatTest.php
@@ -0,0 +1,26 @@
+toBe('16842794');
+
+ $parts = Sonyflake::parseWithEpoch($id, $epoch, SonyflakeFormat::UPSTREAM);
+ expect($parts['sequence'])->toBe(1)
+ ->and($parts['machine_id'])->toBe(42)
+ ->and($parts['time']->format('Uv'))->toBe((string) ($epoch + 10));
+});
+
+test('Sonyflake legacy mode keeps UID field ordering', function (): void {
+ $epoch = 1_577_836_800_000;
+ $id = (string) ((1 << 24) | (42 << 8) | 1);
+ $parts = Sonyflake::parseWithEpoch($id, $epoch);
+
+ expect($parts['sequence'])->toBe(1)
+ ->and($parts['machine_id'])->toBe(42);
+});
diff --git a/tests/Uid6RegressionTest.php b/tests/Uid6RegressionTest.php
new file mode 100644
index 0000000..05bfd0c
--- /dev/null
+++ b/tests/Uid6RegressionTest.php
@@ -0,0 +1,47 @@
+toBeFalse()
+ ->and(NanoID::isValid("abc\n"))->toBeFalse()
+ ->and(TBSL::isValid(str_repeat('0', 20) . "\n"))->toBeFalse();
+});
+
+test('mixed comparator order is transitive', function (): void {
+ expect(IdComparator::sort(['1a', '10', '2']))->toBe(['2', '10', '1a'])
+ ->and(IdComparator::compare('2', '10'))->toBeLessThan(0)
+ ->and(IdComparator::compare('10', '1a'))->toBeLessThan(0)
+ ->and(IdComparator::compare('2', '1a'))->toBeLessThan(0);
+});
+
+test('guid fallback braces normalize cleanly', function (): void {
+ $guid = UUID::guid(false);
+ expect($guid)->toMatch('/^\{[0-9a-f-]{36}\}$/i')
+ ->and(UUID::isValid(trim($guid, '{}')))->toBeTrue()
+ ->and(UUID::fromBytes(UUID::toBytes($guid)))->toBe(strtolower(trim($guid, '{}')));
+});
+
+test('signed numeric decoders reject the unsigned 64-bit maximum', function (): void {
+ $bytes = str_repeat("\xff", 8);
+ expect(fn(): string => Snowflake::fromBytes($bytes))
+ ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class)
+ ->and(fn(): string => Sonyflake::fromBytes($bytes))
+ ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class);
+});
+
+test('opaque ids reject decoded values outside the generation domain', function (): void {
+ $token = BaseEncoder::encodeBytes(str_repeat("\xff", 8), 62);
+ expect(fn(): int => OpaqueId::toInt($token))
+ ->toThrow(\InvalidArgumentException::class);
+});
diff --git a/tests/V5CompatibilityTest.php b/tests/V5CompatibilityTest.php
index bbed3c2..80c354c 100644
--- a/tests/V5CompatibilityTest.php
+++ b/tests/V5CompatibilityTest.php
@@ -125,3 +125,119 @@
test('the installed runtime satisfies the 64-bit package contract', function () {
expect(PHP_INT_SIZE)->toBe(8);
});
+
+
+test('v5 public helper and facade parameter names remain stable', function (): void {
+ $functions = [
+ 'Infocyph\\UID\\cuid2' => ['length'],
+ 'Infocyph\\UID\\ksuid' => ['dateTime'],
+ 'Infocyph\\UID\\nano_id' => ['length'],
+ 'Infocyph\\UID\\object_id' => ['dateTime'],
+ 'Infocyph\\UID\\randflake' => ['config'],
+ 'Infocyph\\UID\\random_id' => ['length', 'alphabet'],
+ 'Infocyph\\UID\\snowflake' => ['config'],
+ 'Infocyph\\UID\\sonyflake' => ['config'],
+ 'Infocyph\\UID\\tbsl' => ['config'],
+ 'Infocyph\\UID\\type_id' => ['type'],
+ 'Infocyph\\UID\\ulid' => ['dateTime', 'mode'],
+ 'Infocyph\\UID\\uuid1' => ['node'],
+ 'Infocyph\\UID\\uuid3' => ['namespace', 'string'],
+ 'Infocyph\\UID\\uuid4' => [],
+ 'Infocyph\\UID\\uuid5' => ['namespace', 'string'],
+ 'Infocyph\\UID\\uuid6' => ['node'],
+ 'Infocyph\\UID\\uuid7' => ['dateTime'],
+ 'Infocyph\\UID\\uuid8' => ['node'],
+ 'Infocyph\\UID\\xid' => [],
+ ];
+
+ foreach ($functions as $function => $expected) {
+ $actual = array_map(
+ static fn(ReflectionParameter $parameter): string => $parameter->getName(),
+ (new ReflectionFunction($function))->getParameters(),
+ );
+ expect($actual)->toBe($expected);
+ }
+
+ $methods = [
+ 'cuid2' => ['length'],
+ 'deterministic' => ['payload', 'length', 'namespace'],
+ 'ksuid' => ['dateTime'],
+ 'nanoId' => ['length'],
+ 'objectId' => ['dateTime'],
+ 'randflake' => ['config'],
+ 'random' => ['length', 'alphabet'],
+ 'snowflake' => ['config'],
+ 'snowflakeValue' => ['config'],
+ 'sonyflake' => ['config'],
+ 'sonyflakeValue' => ['config'],
+ 'tbsl' => ['config'],
+ 'typeId' => ['type'],
+ 'ulid' => ['dateTime', 'mode'],
+ 'uuid' => ['dateTime'],
+ 'uuid1' => ['node'],
+ 'uuid3' => ['namespace', 'string'],
+ 'uuid4' => [],
+ 'uuid5' => ['namespace', 'string'],
+ 'uuid6' => ['node'],
+ 'uuid7' => ['dateTime'],
+ 'uuid8' => ['node'],
+ 'xid' => [],
+ ];
+
+ foreach ($methods as $method => $expected) {
+ $actual = array_map(
+ static fn(ReflectionParameter $parameter): string => $parameter->getName(),
+ (new ReflectionMethod(\Infocyph\UID\Id::class, $method))->getParameters(),
+ );
+ expect($actual)->toBe($expected);
+ }
+});
+
+test('v5 configuration constructor parameters remain an unchanged prefix', function (): void {
+ $contracts = [
+ \Infocyph\UID\Configuration\SnowflakeConfig::class => [
+ 'datacenterId', 'workerId', 'nodeResolver', 'customEpoch',
+ 'sequenceProvider', 'clockBackwardPolicy',
+ ],
+ \Infocyph\UID\Configuration\SonyflakeConfig::class => [
+ 'machineId', 'machineIdResolver', 'customEpoch',
+ 'sequenceProvider', 'clockBackwardPolicy',
+ ],
+ \Infocyph\UID\Configuration\TBSLConfig::class => [
+ 'machineId', 'sequenced', 'machineIdResolver',
+ 'sequenceProvider', 'clockBackwardPolicy',
+ ],
+ \Infocyph\UID\Configuration\RandflakeConfig::class => [
+ 'nodeId', 'leaseStart', 'leaseEnd', 'secret', 'sequenceProvider',
+ ],
+ ];
+
+ foreach ($contracts as $class => $expectedPrefix) {
+ $constructor = (new ReflectionClass($class))->getConstructor();
+ expect($constructor)->not->toBeNull();
+
+ $names = array_map(
+ static fn(ReflectionParameter $parameter): string => $parameter->getName(),
+ $constructor?->getParameters() ?? [],
+ );
+ expect(array_slice($names, 0, count($expectedPrefix)))->toBe($expectedPrefix);
+ }
+});
+
+test('legacy Snowflake and Sonyflake stored-layout vectors still parse identically', function (): void {
+ $epoch = 1_577_836_800_000;
+ $snowflake = (string) ((1 << 22) | (2 << 17) | (3 << 12) | 4);
+ $snowflakeParts = \Infocyph\UID\Snowflake::parseWithEpoch($snowflake, $epoch);
+
+ expect($snowflakeParts['sequence'])->toBe(4)
+ ->and($snowflakeParts['worker_id'])->toBe(3)
+ ->and($snowflakeParts['datacenter_id'])->toBe(2)
+ ->and($snowflakeParts['time']->format('Uv'))->toBe((string) ($epoch + 1));
+
+ $sonyflake = (string) ((1 << 24) | (42 << 8) | 1);
+ $sonyflakeParts = \Infocyph\UID\Sonyflake::parseWithEpoch($sonyflake, $epoch);
+
+ expect($sonyflakeParts['sequence'])->toBe(1)
+ ->and($sonyflakeParts['machine_id'])->toBe(42)
+ ->and($sonyflakeParts['time']->format('Uv'))->toBe((string) ($epoch + 10));
+});
diff --git a/tests/smoke.php b/tests/smoke.php
new file mode 100644
index 0000000..844ccda
--- /dev/null
+++ b/tests/smoke.php
@@ -0,0 +1,140 @@
+> $seen */
+function uidSmokeId(string $algorithm, string $id, bool $valid, array &$seen): void
+{
+ if (!$valid || isset($seen[$algorithm][$id])) {
+ throw new RuntimeException($algorithm . ' produced an invalid or repeated ID');
+ }
+
+ $seen[$algorithm][$id] = true;
+}
+
+$directory = sys_get_temp_dir() . '/uid-smoke-' . bin2hex(random_bytes(6));
+mkdir($directory, 0700) || throw new RuntimeException('Unable to create smoke state directory');
+$provider = new FilesystemSequenceProvider($directory, 'smoke');
+$runtime = new GenerationContext(waitTimeoutMicros: 100_000);
+$snowflake = new SnowflakeConfig(datacenterId: 1, workerId: 2, sequenceProvider: $provider, runtime: $runtime);
+$sonyflakes = [];
+$randflakes = [];
+$secret = '0123456789abcdef';
+foreach (SonyflakeFormat::cases() as $format) {
+ $sonyflakes[] = new SonyflakeConfig(machineId: 42, sequenceProvider: $provider, runtime: $runtime, format: $format);
+}
+foreach (RandflakeFormat::cases() as $format) {
+ $randflakes[] = new RandflakeConfig(7, time() - 5, time() + 300, $secret, $provider, $runtime, $format);
+}
+$tbsl = new TBSLConfig(machineId: 9, sequenceProvider: $provider, runtime: $runtime);
+$binaryGenerators = [
+ 'ULID monotonic' => [ULID::generateMonotonic(...), ULID::class],
+ 'ULID random' => [ULID::generateRandom(...), ULID::class],
+ 'ObjectID' => [ObjectID::generate(...), ObjectID::class],
+ 'KSUID' => [KSUID::generate(...), KSUID::class],
+ 'XID' => [XID::generate(...), XID::class],
+];
+$randomGenerators = [
+ CUID2::class => CUID2::generate(...),
+ NanoID::class => NanoID::generate(...),
+ RandomId::class => RandomId::generate(...),
+];
+$seen = [];
+
+try {
+ for ($cycle = 0; $cycle < 100; ++$cycle) {
+ foreach ([1, 3, 4, 5, 6, 7, 8] as $version) {
+ $id = match ($version) {
+ 1 => UUID::v1(),
+ 3 => UUID::v3('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle),
+ 4 => UUID::v4(),
+ 5 => UUID::v5('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle),
+ 6 => UUID::v6(),
+ 7 => UUID::v7(),
+ 8 => UUID::v8(),
+ };
+ uidSmokeId('UUID v' . $version, $id, UUID::isValid($id)
+ && UUID::parse($id)['version'] === $version
+ && UUID::fromBytes(UUID::toBytes($id)) === $id, $seen);
+ }
+ $id = UUID::guid(false);
+ uidSmokeId('GUID', $id, preg_match('/\A\{[0-9a-f-]{36}\}\z/i', $id) === 1
+ && UUID::isValid(trim($id, '{}')), $seen);
+
+ foreach ($binaryGenerators as $name => [$generate, $class]) {
+ $id = $generate();
+ uidSmokeId($name, $id, $class::isValid($id)
+ && $class::fromBytes($class::toBytes($id)) === $id, $seen);
+ }
+ foreach ($randomGenerators as $class => $generate) {
+ $id = $generate();
+ uidSmokeId($class, $id, $class::isValid($id), $seen);
+ }
+ $id = TypeID::generate('smoke');
+ uidSmokeId('TypeID', $id, TypeID::isValid($id)
+ && TypeID::fromUuid('smoke', TypeID::toUuid($id)) === $id, $seen);
+ $id = Snowflake::generateWithConfig($snowflake);
+ $parsed = Snowflake::parse($id);
+ uidSmokeId('Snowflake', $id, Snowflake::isValid($id)
+ && $parsed['datacenter_id'] === 1 && $parsed['worker_id'] === 2
+ && Snowflake::fromBytes(Snowflake::toBytes($id)) === $id, $seen);
+
+ foreach ($sonyflakes as $config) {
+ $id = Sonyflake::generateWithConfig($config);
+ uidSmokeId('Sonyflake ' . $config->format->value, $id, Sonyflake::isValid($id)
+ && Sonyflake::parse($id, $config->format)['machine_id'] === 42
+ && Sonyflake::fromBytes(Sonyflake::toBytes($id)) === $id, $seen);
+ }
+ foreach ($randflakes as $config) {
+ $id = Randflake::generateWithConfig($config);
+ uidSmokeId('Randflake ' . $config->format->value, $id, Randflake::isValid($id, $config->format)
+ && Randflake::inspect($id, $secret, $config->format)['node_id'] === 7
+ && Randflake::fromBytes(Randflake::toBytes($id, $config->format), $config->format) === $id, $seen);
+ }
+ foreach (['sequenced' => TBSL::generateWithConfig($tbsl), 'random' => TBSL::generateRandom(9)] as $mode => $id) {
+ uidSmokeId('TBSL ' . $mode, $id, TBSL::isValid($id)
+ && TBSL::parse($id)['machineId'] === 9
+ && TBSL::fromBytes(TBSL::toBytes($id)) === $id, $seen);
+ }
+ $id = OpaqueId::fromInt($cycle, 'smoke');
+ uidSmokeId('OpaqueId', $id, OpaqueId::toInt($id, 'smoke') === $cycle, $seen);
+ $id = DeterministicId::fromPayload('smoke-' . $cycle, 24, 'smoke');
+ uidSmokeId('DeterministicId', $id, RandomId::isValid($id, 24, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
+ && DeterministicId::fromPayload('smoke-' . $cycle, 24, 'smoke') === $id, $seen);
+ }
+ foreach ($seen as $algorithm => $ids) {
+ printf("PASS %s: %d IDs\n", $algorithm, count($ids));
+ }
+ printf("Passed %d generator variants in one 100-cycle smoke pass.\n", count($seen));
+} finally {
+ foreach (glob($directory . '/*') ?: [] as $path) {
+ unlink($path);
+ }
+ rmdir($directory);
+}