From ed56fbffa74db15a4423aca7df1e270b1027f023 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 20:56:17 +0600 Subject: [PATCH 001/107] :sparkles: ci(workflow): update security workflow and release plan - Update GitHub Actions workflow to support release tags and add release job :construction_worker: - Clean up and organize .gitignore entries for development and build files :fire: - Add comprehensive UID 6.0 full improvement and release plan documentation :memo: --- .github/workflows/security-standards.yml | 10 + .gitignore | 20 +- docs/uid-review-and-release-plan.md | 496 +++++++++++++++++++++++ 3 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 docs/uid-review-and-release-plan.md diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 7f4e52b..fb4c037 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,11 +5,13 @@ 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 @@ -21,3 +23,11 @@ jobs: 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/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md new file mode 100644 index 0000000..bd5ff49 --- /dev/null +++ b/docs/uid-review-and-release-plan.md @@ -0,0 +1,496 @@ +# UID 6.0 full improvement and release plan + +Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3cef0fa`. +Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. +Status: full scope planned; implementation and release acceptance remain open. + +This plan follows `vendor/infocyph/phpforge/resources/engineering-principles.md`: +correctness and security precede performance; preserve public contracts and named +arguments; distinguish required changes from optional features; keep dependencies +and abstractions justified; use successful host RPM as the performance criterion; +resolve quality findings at their cause without suppressions or weaker gates. +No production code or dependency constraints were changed during this review. + +## Complete planned scope + +The requested scope includes every review finding and every previously listed +improvement. Enhancements are included delivery work; optional integrations and +format modes remain optional for consumers. Profiling work must finish with a +measured implementation decision, even when the correct decision is to retain +the existing algorithm. No item is left as an unspecified later wishlist. + +Target **6.0.0** for the complete scope. Correcting the public mixed-ID comparison +contract and freezing mutable epoch configuration can change observable behavior; +include those changes in a major release with migration coverage. The next +release requires **PHP 8.4 or newer on a 64-bit runtime**, as requested. Preserve +legacy stored ID formats and include the PHP minimum change in the migration guide. + +| Delivery area | Included changes | Implementation section | +| --- | --- | --- | +| Required safety fixes | R01/R02/R08: secure state and locks, authoritative allocation, loss/exhaustion handling | A | +| Required correctness fixes | R03–R07/R09–R11: generation state, counters, validation, lease retries, worker state, numeric codecs, comparison and GUIDs | B | +| Required release hygiene | R12–R14: tooling, support matrix, dependency metadata, docs and secret redaction | C | +| Included runtime enhancement | Passed Runwire 2.1.1 context/request/task instances, capability selection, cooperative waits and lifecycle-safe fallback | D | +| Included format enhancement | Explicit upstream-compatible Sonyflake/Randflake modes, legacy decoding and migration | E | +| Included configuration enhancement | Injected clocks, bounded waiting and immutable epoch normalization | F | +| Included performance work | CUID2/base-codec profiling and justified optimizations, production host benchmarks and soak | G and release gates | + +Implement A–C first, then F's time/configuration boundaries, D's runtime binding +and E's explicit formats. Complete G against the resulting common and bound paths. +Run final acceptance after all included changes are present. Keep each cohesive +change reviewable and regression-covered; do not fold unrelated repository cleanup +into this release. + +## Review scope and evidence + +The review covered all production generator families, configuration objects, +sequence providers, binary/base codecs, value objects, comparator, helpers, +tests, benchmark harnesses, Composer metadata, documentation and CI wrapper. +Graphify supplied navigation; findings below were verified in source and with +targeted PHP probes. Reflection was used only to reach otherwise impractical +counter boundaries, not as evidence that attackers can mutate private state. + +Current local evidence on 64-bit PHP 8.5.4: + +| Check | Result | +| --- | --- | +| `composer ic:doctor` / `composer ic:list-config` | Doctor healthy; configurations resolve from PHPForge | +| `composer validate --strict` | Passed | +| `composer ic:test:code` | 159 tests, 1,531 assertions, passed; process/fork tests executed | +| `composer ic:tests` | Failed skip-directive scanner and PHPStan configuration validation | +| Other full-suite stages | Normalize, syntax, references, duplicates, comments, Pest, Pint, PHPCS, Deptrac, Psalm and Rector passed | +| `composer audit --locked --format=json` | Zero advisories; abandoned development dependency `doctrine/annotations`; audit exits 1 for abandonment | +| `composer ic:release:guard` with network access | Audit completed with abandonment treated as a warning; guard failed at skip scanning/configuration | +| TypeID upstream vectors | All 9 valid encoding/decoding cases and 21 invalid cases passed | +| Targeted adversarial probes | Reproduced findings R01–R11 below | + +The live [Security & Standards run](https://github.com/infocyph/UID/actions/runs/37178593330) +for the reviewed SHA failed all four QA lanes: PHP 8.4/8.5 with prefer-stable and +prefer-lowest. Its analysis lanes, clean install and component benchmark passed. +Job details show failures at `Run quality suite once`; the requested QA log +download returned empty output, so its precise diagnostic is not attributed to +the local failure. Benchmark result validation and regression comparison were +skipped: a green component benchmark job is not a 2% host-RPM certification. + +PHP 8.2/8.3 execution, a Windows run, representative host throughput and a long +persistent-worker soak were not performed. The existing documented August +component timings are historical supporting evidence, not current release gates. +Zero published dependency advisories does not establish absence of code defects. +The PHP 8.2/8.3 coverage gap describes the reviewed 5.x tree; the next release's +required runtime matrix begins at PHP 8.4. + +## Required findings + +Severity describes impact and prerequisites, rather than a claimed CVSS score. + +| ID | Priority | Finding and verified evidence | Owner | +| --- | --- | --- | --- | +| R01 | High, conditional local security | `FileLock::acquire()` opens predictable files with `fopen(..., 'c+')` and follows symlinks. A pre-existing `uid-test-1.seq` symlink caused its writable target to become `100,1`. The default shared temporary directory permits precreation attacks by another local user. Cache fallback locks share the opener and can be redirected or obstructed too. This is not a demonstrated remote attack. | `src/Support/FileLock.php:21`, filesystem/cache providers | +| R02 | High, conditional data integrity | PSR-16 state loss restarts allocation at 1 for the same timestamp. Clearing the cache between two calls on the same Randflake config produced identical IDs within one second. Distributed locking alone cannot recover evicted, expired, cleared or lost allocation state. A null TTL may use the backend's default lifetime. | `src/Sequence/PsrSimpleCacheSequenceProvider.php:114`, `src/Randflake.php:256`, provider docs | +| R03 | Medium, ordinary composition | Random ULID generation overwrites `lastRandChars` used by monotonic mode without updating its timestamp. Monotonic → random → monotonic at one timestamp can move backwards. A seeded boundary probe produced `...YYYYYYYYYYYYYYYZ` followed by a smaller random-derived tail. | `src/ULID.php:94` | +| R04 | Medium, extremely rare counter boundary | ULID overflow clears all tail digits before throwing. Calling again at the same explicit timestamp emits `01HF7YAT3V0000000000000001` instead of staying exhausted. This can reuse prior values. | `src/ULID.php:102`, `incrementRandomState()` | +| R05 | Medium, extremely rare counter boundary | UUIDv7 increments an 80-bit tail, then overwrites version and variant bits. Carrying into the variant bits made `018bcfe5-687b-7000-bfff-ffffffffffff` become the smaller `018bcfe5-687b-7000-8000-000000000000`. Version-bit carries have the same underlying problem. | `src/UUID.php:637`, `output()` | +| R06 | Medium, input boundary | ULID, NanoID and TBSL regexes use `$` without strict end-of-input matching and accept one trailing newline. ULID binary conversion silently discards it; TBSL validation and byte decoding disagree. | `src/ULID.php:169`, `src/NanoID.php:40`, `src/TBSL.php:87` | +| R07 | Medium, delayed/retried coordination | Randflake validates a lease before allocation, but resamples time after a provider timestamp exception without checking the lease/lifetime again. A delayed first callback followed by a retry generated an ID with a timestamp later than `leaseEnd`. | `src/Randflake.php:261` | +| R08 | Medium, boundary and operational reliability | Filesystem reservation arithmetic overflows intermediate integer expressions. Starting from `100,9223372036854775806`, size 1 returned the final integer but persisted `100,9.2233720368548E+18`; the next cached allocation raised `TypeError`. | `src/Sequence/FilesystemSequenceProvider.php:78` | +| R09 | Medium, persistent-worker stability | Filesystem `pathCache` is capped at 1,024, but `reservations` retained 1,050 keys after 1,050 domains, even with size 1. Sonyflake retains string-keyed static state after providers disappear; a probe released 250 provider/config domains and retained all 250 entries. `spl_object_id()` reuse can also transfer stale state to an unrelated provider. | `src/Sequence/FilesystemSequenceProvider.php:87`, `src/Sonyflake.php:34`, `generateInternal()` | +| R10 | Medium, public utility contracts | Snowflake/Sonyflake decode eight `ff` bytes to `18446744073709551615`, which their own validators reject. OpaqueId decodes the corresponding full unsigned token `LygHa16AHYF` to `-1`, outside its generation domain. | Numeric decoding in `src/Snowflake.php`, `src/Sonyflake.php`, `src/OpaqueId.php:30` | +| R11 | Medium/low, public utilities | Comparator relations form a cycle: `2 < 10`, `10 < 1a`, `1a < 2`. Sorting mixed numeric/text IDs has no consistent total order. Separately, `UUID::guid(false)` returns literal `\{...\}` on the PHP fallback path, and UUID normalization rejects it. | `src/IdComparator.php:15`, `src/UUID.php:146` | +| R12 | Required release gate | Three `markTestSkipped()` directives fail the strict scanner even though their fork prerequisites exist locally. Installed PHPForge configuration declares `dependency_tree` and `dependency_tree_types`, which installed cognitive-complexity 1.3.0 does not accept. `ic:active-config` also fails. Hosted QA is red. | Tests, PHPForge configuration/dependency pairing, CI | +| R13 | Required portability gate | Composer promises PHP 8.2+, but the reusable workflow currently resolves only 8.4/8.5. Production code also calls `ctype_digit()`/`ctype_xdigit()` without declaring `ext-ctype`. The current host provides ctype, so this is a metadata/support gap, not a reproduced host failure. | `composer.json`, PHPForge runtime matrix | +| R14 | Required documentation accuracy | UUID docs claim a v7 node argument and an `isValid` parse field; neither exists. `UUID::v7(null, $node)` silently ignores the extra positional argument. NanoID docs incorrectly describe customizable rejection sampling instead of its fixed Base64url construction. Sonyflake/Randflake references need to distinguish UID's formats from upstream wire compatibility. | `docs/uuid.rst`, `docs/random-ids.rst`, `docs/compatibility.rst`, references | + +### Format and security boundaries + +UID's Sonyflake field order is time/machine/sequence. The [upstream implementation](https://raw.githubusercontent.com/sony/sonyflake/master/sonyflake.go) +uses time/sequence/machine. With epoch `1577836800000`, upstream components +elapsed=1, sequence=1, machine=42 encode as `16842794`; UID decodes sequence=42, +machine=256. Current UID docs already describe its 39/16/8 order: preserve that +stored format and explicitly describe it as a UID variant. An upstream-compatible +mode is a separate feature, not a silent parser correction. + +UID Randflake uses its own eight-round Feistel permutation. [Upstream Randflake](https://github.com/gosuda/randflake) +uses SPARX64, different byte interpretation and signed decimal presentation. +The [upstream vector file](https://raw.githubusercontent.com/gosuda/randflake/main/test_vectors.json) +defines zero-key token `1qjeojjevu31n` as timestamp=1730000001, node=0, +sequence=0. UID decodes it as timestamp=1999128447, node=39131, +sequence=39141. This proves a compatibility difference, not a cryptanalytic +break. State explicitly that UID's custom permutation has no established +cryptographic security claim; retain the existing warning that inspection does +not authenticate an ID. Do not replace the permutation silently for stored IDs. + +The [TypeID 0.3 specification](https://raw.githubusercontent.com/jetify-com/typeid/main/spec/README.md) +allows user-supplied UUID variants while requiring v7 for newly generated IDs. +UID's permissive `fromUuid()` behavior fits that contract and should be preserved. +The [ULID specification](https://github.com/ulid/spec) and +[RFC 9562 section 6.2](https://datatracker.ietf.org/doc/html/rfc9562#section-6.2) +support the monotonicity/overflow acceptance cases for R03–R05. + +Positive observations: CSPRNG-backed generation is retained; RandomSampler +uses unbiased rejection sampling; bounded binary/base decoders and malformed +persisted-state rejection are already present; process-random ID families and +filesystem reservations include fork checks; PSR-16 distributed synchronizers +are explicit; loading helpers performs declarations rather than discovery/I/O. +No claim of cryptographic certification is made for a full-library code review. + +## Implementation sequence + +### A. Filesystem and authoritative allocation safety + +- [ ] Reproduce R01 using private fixtures, then protect the existing lock/state + owner against symlinks, non-regular files, unsafe ownership and precreation. + Use an application-owned restricted directory where possible. Check file + identity and ownership on the opened handle; a path check alone leaves a race. + Never open an unverified target with truncating writes. +- [ ] Preserve a stable lock inode while coordinating writers. Renaming a state + file underneath locks can let writers lock different inodes. +- [ ] If secure default storage changes location, provide a coordinated migration + that preserves sequence high-water marks. Mixed old/new paths or independent + empty stores must not create two allocation authorities for the same domain. +- [ ] Fix R08 with integer-safe bounds checked before increment/reservation, + including exhaustion, maximum allocation, cached-next and write failures. +- [ ] For R02, define shared allocation state as authoritative, non-expiring and + non-evicting while its timestamp can still be emitted. Require appropriately + durable storage and cross-host synchronization; generic PSR-16 cannot prove + those guarantees. Document backend default TTL, clearing, failover, restoration, + machine ownership and restart requirements. Fail closed when known state is + lost or allocations regress; a local guard alone is not a distributed fix. +- [ ] Add repeated-allocation detection to Randflake's stable provider/domain + state so ordinary same-instance state loss cannot emit a known duplicate. + Cover restart/new-provider limitations explicitly. A durable external provider + can use the existing `SequenceProviderInterface`/callback boundary. +- [ ] Preserve the current rule that the fallback cache lock coordinates only + cooperating processes on one host/filesystem. A Runwire mutex cannot replace + a distributed lock or an authoritative allocation store. + +Acceptance: adversarial link/precreation tests leave target files untouched; +counter exhaustion persists canonical state and yields domain exceptions; +state-loss probes emit no repeated IDs in the supported configuration; +cross-process allocation and migration produce zero duplicate IDs. Test lock +timeout, partial write, corrupt state, process termination and restart. `fflush()` +is not power-loss durability: document filesystem durability limits and use a +durable backend where that guarantee is required. + +### B. Generator, validation and utility correctness + +- [ ] Separate ULID random-mode work from its monotonic state. Make overflow + state terminal for that timestamp; use a non-mutating overflow decision or + commit a new tail only after successful increment. Check timestamp range again + after any wait. Cover alternating modes and repeated calls after exceptions. +- [ ] Increment only UUIDv7's usable 74 random bits, carrying across `rand_b` + and `rand_a` while keeping version/variant fixed. Define full exhaustion and + explicit timestamp behavior; preserve existing timestamp/output contracts. +- [ ] Require exact end-of-input and protocol widths for R06. Align validation, + parse and byte conversion. Preserve intentionally supported UUID input forms; + do not turn every normalization helper into an unrelated strictness migration. +- [ ] Revalidate Randflake lease, timestamp lifetime and rollback conditions on + every resampled retry before consuming another allocation. Retain UID's + documented inclusive lease end in a compatible release. +- [ ] Reject decoded numeric IDs outside each family's signed/non-negative + domain. Test zero, maximum valid value, first invalid value, all-`ff` bytes and + every supported base, plus value-object construction. +- [ ] Establish a total mixed-ID order for R11, for example numeric values first, + numeric comparison within that group, and lexical comparison within the text + group. Test transitivity and shuffled input permutations. Numeric-only and + text-only ordering remain stable. Review changes to previously ambiguous mixed + ordering against consumers before release; if its pairwise contract must be + preserved, add explicit modes and schedule the default correction for a major. +- [ ] Correct the PHP GUID brace fallback and test normalization/round trips. +- [ ] Keep provider-instance state weakly associated with the actual provider; + replace Sonyflake's reusable object-ID keys. Bound reservation/state metadata + without resetting live uniqueness or rollback guards. Do not retain empty + reservation bookkeeping for size 1 without a demonstrated need. +- [ ] Define a bounded number of live configured domains for persistent workers. + Never blindly evict safety state and allow a previously used allocation domain + to restart. Verify fork, released providers, reused object IDs and request isolation. + +Acceptance: each reported defect has a regression that fails on the reviewed +revision and passes after remediation. Boundary tests use controlled state and +time; common-path randomness remains PHP's CSPRNG. Independent golden vectors +verify codecs and protocol envelopes rather than only self-round trips. + +### C. Toolchain, support contracts and documentation + +- [ ] Resolve the cognitive-complexity/PHPForge configuration pairing in its + owning package. Do not edit `vendor/`, remove the requested checks, add baselines + or suppress errors. Refresh UID's development resolution once that fix is + available; verify the same detector and intended rules actually execute. +- [ ] Replace skip directives with clear prerequisite assertions for the + designated process-test environment and supply `pcntl`/process support there. + Keep meaningful single-process coverage for other platforms. Any suite split + must be an explicit portability design; the process suite remains a required + release lane and is never hidden to satisfy the scanner. +- [ ] Set Composer runtime requirements to `php: ^8.4` and `php-64bit: ^8.4` + for the next release. Update installation, requirements and compatibility docs + together. PHP 8.2/8.3 support ends with the 5.x line; document the upgrade path. +- [ ] Verify production source and tooling on real PHP 8.4 and PHP 8.5 in stable + and lowest-compatible dependency lanes. Test subsequent supported PHP 8.x + versions as they become available; do not claim PHP 9 compatibility from a + lower-bound requirement alone. Require platform checks on clean production + installs and do not use Composer platform emulation as execution evidence. +- [ ] Declare mandatory ctype support or remove that dependency with equivalent, + measured validation. Keep PSR-16 optional and production installs free of tooling. +- [ ] Address abandoned dev-package usage through PHPForge/PHPBench ownership; + do not substitute a new UID production dependency to fix a tooling concern. +- [ ] Fix R14 and publish explicit UID-specific Sonyflake/Randflake compatibility + notes. Include identifier selection, collision budgets for short configurable + outputs, unique storage constraints and independent authorization requirements. +- [ ] Redact Randflake secret-bearing callable parameters with + `#[SensitiveParameter]`; consider configuration-object exposure separately. + Attribute redaction does not hide a public property or authorize logging it. + +Acceptance: strict full suite and release guard pass on the final source and +dependency set, no new suppression/skip directives, supported-runtime execution +and clean production installation evidence, accurate executable documentation. + +## D. Included Runwire 2.1.1 integration + +Include an integration focused on coordinated generators and blocking waits, +with representative host measurements as an acceptance gate. Installation and +binding remain optional for consumers. Runwire is not needed for correctness of +unbound generation and provides no clear throughput +advantage for individual UUID, ULID, NanoID, ObjectID, CUID2 or codec CPU operations. + +The local Runwire repository's exact `2.1.1` tag was inspected. It requires +PHP 8.4+, matching the next UID release's minimum. `RuntimeContext` exposes +capability/worker metadata, not an injectable distributed allocator or loop +handle. `RequestContext` provides runtime identity, completion, deadline and +cancellation. `CoroutineScope` provides cooperative sleep and local synchronization. + +### Proposed instance-based binding + +- [ ] Use one small operation binding, provisionally `RunwireBinding`, constructed + from the host's `RuntimeContext`, optional `RequestContext` and optional + `CoroutineScope`. Let generation configs and coordination providers accept it + through additive instance APIs. Resolve feature support at binding time. +- [ ] Reuse existing config/provider APIs and stable underlying sequence state. + Do not build a second wrapper hierarchy for every generator or clone allocation + authority whenever a request binding is created. +- [ ] Forward the identical host context/scope references through framework → UID + and framework → another library → UID. Intermediaries may pass the binding, + config or provider instance; they must not discover a different global runtime. +- [ ] Bind after worker creation/fork. Validate PID, runtime/request identity and + completed request state; reject stale/cancelled bindings. Do not retain a request + binding in static provider selectors or worker-wide mutable globals. +- [ ] Use public 2.1.1 APIs only. In particular, scope has no public `closed()` + accessor: its public `hasLocal(TaskLocal)` checks scope openness before querying + the scheduler. A private library-owned key can validate a passed active scope + without installing host task-local state. Verify use within the active scheduler + and cover closed scopes before allocation; do not depend on private internals. +- [ ] Use `RequestContext::cancellation` and `CoroutineScope::cancellation()` + together; cancellation or deadline expiry in either stops new allocation. + Check after every cooperative suspension and immediately before mutation. +- [ ] With an active scope and coroutine support, retry `flock(LOCK_EX | LOCK_NB)` + with bounded `scope->sleep()` rather than blocking the event loop. Locks are + not socket readiness: do not register a lock file as an async writable stream. +- [ ] Apply the same bounded cooperative strategy to configured clock-rollover + waits. Use `hrtime()`/Runwire deadlines for wait budgets and wall time for ID + timestamps and leases. Never derive a Unix ID timestamp from a monotonic clock. +- [ ] Re-read/revalidate mutable reservation and sequence state after suspension. + Keep critical state mutation free of yields. A yielding remote provider needs + its own serialization/atomicity contract; merely passing a scope cannot supply it. +- [ ] Release only UID-owned handles in `finally`. Never start/stop a runtime, + spawn a worker pool, take over an event loop, complete the host request, close + the host scope or cancel unrelated host tasks. +- [ ] A committed allocation remains consumed if cancellation arrives afterward; + gaps are acceptable. Do not recycle allocations or retry a possibly committed + remote write as though it had not happened. +- [ ] With no Runwire or no cooperative capability, use the normal synchronous + path and its configured limits. Missing capability is a fallback condition; + cancellation, corruption, failed authoritative storage and closed/stale scope + are terminal errors. Preserve the selected authoritative sequence provider. +- [ ] Suggest Runwire to consumers and use it in PHP 8.4+ test fixtures. Keep it + optional at runtime and test clean supported-PHP installs without Runwire. + +Acceptance matrix: direct and intermediary instance forwarding; no Runwire +installed; present but no coroutine scope/capability; active scope with contended +lock; pre-cancelled and expired request/task; cancellation while waiting and +immediately before state mutation; cancellation after allocation commit; mismatched +runtime/PID; completed request; closed scope; fork and worker replacement; no +host-loop blocking; no lifecycle ownership changes; concurrent requests sharing +one authoritative provider without request-state leakage or duplicate IDs. + +A host worker slot/generation is useful lifecycle metadata, not a globally unique +Snowflake/Sonyflake node lease. Preserve explicitly coordinated node/machine IDs. +Do not automatically select the process-memory provider for persistent runtimes. + +## E. Included upstream-compatible formats and migration + +- [ ] Add explicit Sonyflake format selection to generation configuration and + parsing/value APIs. Keep the existing UID time/machine/sequence format readable + and selectable; add upstream time/sequence/machine behavior as a separate mode. + Keep numeric storage and epoch units explicit at both generation and parsing. +- [ ] Define epoch behavior for each mode and require the same epoch on both + sides of an interoperability test. Never infer an epoch from an unlabelled ID + or reuse a custom epoch merely because its integer happens to fit. +- [ ] Add explicit Randflake format selection. Preserve decoding and generation + for the current UID Feistel format and add the upstream SPARX64 format with its + exact byte order, signed decimal representation and base32hex contract. + Do not approximate the cipher or substitute a faster custom permutation. +- [ ] Pin the upstream reference revision used for each implementation and its + golden vectors. Validate independent encoding, decoding and generation cases, + including the upper timestamp range and negative upstream decimal values. +- [ ] Make Randflake lease-end semantics explicit per format. Preserve inclusive + `leaseEnd` for UID legacy mode; use a clearly named exclusive boundary for the + upstream contract. Document the translation from an inclusive end to an + exclusive end and validate lifetime/overflow limits during conversion. +- [ ] Include format identity in relevant configuration and coordination-domain + keys where its semantics differ. Share the same authoritative store when + multiple requests/workers generate within the same configured domain. +- [ ] Carry format and epoch metadata in parsed/value representations where needed + for reliable round trips. Raw stored IDs need an external format discriminator: + the same bytes can be valid in multiple formats. Do not guess which permutation + or bit layout produced an unlabelled value. +- [ ] Keep legacy format defaults unless the major-release migration explicitly + changes one. A new upstream mode must not silently reinterpret old stored values. + Document that changing format does not preserve cross-format uniqueness in one + unlabelled integer namespace; use appropriate storage keys/constraints. +- [ ] Provide executable migration examples: retain old rows with legacy metadata, + enable explicit dual-format reads, select the desired format for new writes, + and coordinate writers before changing domains. IDs used as references must not + be rewritten without a consumer-owned transactional relationship migration. +- [ ] Retain clear identifier/authentication boundaries for both modes. Upstream + compatibility is not authentication or independent cryptographic certification. + Mark the legacy custom permutation as obfuscation with no established security + claim; redact secret-bearing parameters for both implementations. +- [ ] Keep implementations owned by their existing generator/codec boundaries. + Add a runtime dependency only if it provides substantial verified value and + supports the package baseline; a format enhancement does not justify a generic + cryptography framework or host-specific allocator infrastructure. + +Acceptance: independent pinned upstream vectors pass alongside unchanged legacy +vectors; explicit mode and epoch round trips work across numeric, binary and text +representations; wrong/missing metadata fails as documented; dual-format consumer +fixtures retain existing identifiers and relationships. Required allocation, +clock, cancellation and worker tests execute for both modes where applicable. + +## F. Included clocks, bounded waits and immutable configuration + +- [ ] Add instance/configuration injection of PSR-20 `ClockInterface` where + generation needs a controllable wall clock. Keep explicit timestamp inputs + usable without another clock abstraction and retain the native fast path when + no clock is supplied. Keep `psr/clock` optional for consumers that use injection; + include development fixtures compatible with the PHP 8.4 minimum. +- [ ] Preserve public parameter names and add clock/configuration options at real + existing API boundaries. Never store a request/tenant clock in static globals or + resolve it repeatedly through a container or runtime singleton. +- [ ] Read wall time once per logical allocation attempt, then deliberately + resample after a retry or rollover wait. Revalidate epoch/lifetime, lease and + rollback conditions for that sample. Pass the computed value through hot + internal work rather than allocating a date object per bit/codec operation. +- [ ] Keep monotonic timeout accounting separate from injected wall time. A frozen + test clock must not disable lock/deadline exhaustion or cause an infinite loop. + Do not substitute Runwire request start time for actual ID generation time. +- [ ] Add explicit bounded wait/retry policy for coordinated generators and lock + acquisition. Cap attempts or elapsed monotonic time, and define a domain failure + when the budget is exhausted. Combine library limits with the earliest active + host request/task deadline; retain those limits on synchronous fallback. +- [ ] Replace TBSL's tight rollback/rollover spin with bounded waiting. Use the + passed scope's cooperative sleep where available and a bounded native wait + otherwise. Measure short normal rollover behavior before selecting intervals. +- [ ] Normalize custom epochs at configuration construction into immutable scalar + milliseconds or immutable date values, and reuse the normalized result. + Mutating a caller-owned `DateTime` later must not change an existing ID domain. + Validate supported epoch/range boundaries and preserve parser metadata. +- [ ] Document the epoch behavior change in the 6.0 migration: construct a new + config to change domains, retain the old epoch to parse existing IDs, and avoid + switching a live generator domain by modifying a shared date object. +- [ ] Use deterministic injected-clock tests for lease boundaries, retry resamples, + rollback, forward jumps, tick rollover, frozen clocks and request cancellation. + Retain controlled private-state probes only for unreachable counter boundaries. + +Acceptance: independent configs/clocks never contaminate one another; mutation of +the original date leaves the configured epoch unchanged; frozen clocks exhaust +their wait budget; cancellation stops before the next allocation mutation; clock +injection and native generation produce equivalent valid timestamps/formats. +Measure clock/date conversion overhead in both isolated and host benchmarks. + +## G. Included CUID2 and codec performance work + +- [ ] Profile CUID2 generation and fingerprint creation separately, including + cold initialization, warm calls, configured lengths and fork reseeding. Measure + SHA3 hashing, Base36 conversion and temporary allocation costs before editing. +- [ ] Profile BaseEncoder, TypeIdCodec and numeric conversions across 8, 10, 12, + 16, 20 and 32 bytes, all supported bases, zero/high-bit/max values, and valid, + invalid and oversized input. Retain a realistic large-input bound test. +- [ ] Implement measured reductions in repeated conversion, copying, callbacks or + temporary arrays inside the existing cohesive owners. Consider a direct bit + codec only for a demonstrated hot compatible base/width; preserve each format's + padding, alphabet, leading-zero and canonical-input behavior. +- [ ] Reuse common logic only when it remains simpler and improves or preserves + sustained host RPM. Generic Base32 and TypeID/Crockford alphabets and padding + are distinct contracts; do not merge them solely because their loops look alike. +- [ ] Preserve CUID2 entropy, digest choice, output distribution/length and fork + safety. Never replace CSPRNG work or weaken identifier security to win a benchmark. +- [ ] Record before/after component measurements and representative request RPM + under matching environments. Keep an optimization only when its measured value + justifies complexity within the release budgets. If no useful candidate wins, + close the profiling item with the measured decision to retain the current code. + +Acceptance: golden vectors and adversarial bounds remain correct, supported modes +produce equivalent valid output, and the selected implementation meets stable host +RPM/resource budgets. Deliver the profiling decision and reproducible measurements; +do not assert an improvement solely from historical microsecond timings. + +## Performance and release gates + +- [ ] Measure corrected code against tag `5.0` with matching runtimes, dependencies, + hardware and deployment configuration. Separate pure-generator, filesystem, + reservation, PSR-16 and optional Runwire-bound workloads. +- [ ] Use representative host routes generating one ID and batches of 100 IDs, + plus contended sequence allocation. Compare at least three warmed sustained + trials at concurrency 1, 5, 20 and 50, extending the curve if necessary to find + saturation. Use at least 60 seconds per measured trial after warm-up. +- [ ] Count only valid successful responses; collect successful RPS/RPM, p50/p95/ + p99, errors/timeouts, output/duplicate failures, CPU, peak/steady RSS, worker + count, lock wait and queue growth. Record extension and OPcache configuration. +- [ ] Use a default maximum 2% median successful-RPM regression on stable + comparable environments. Record variance; noisy results are inconclusive. + Bound errors, timeouts and invalid/duplicate IDs at zero in the accepted + supported workload; queues must not grow progressively. Set workload-specific + p99, wait and memory ceilings before measurement and explain capacity choices. +- [ ] Run component benchmarks and the existing contention matrix as supporting + diagnostics. They do not establish host application throughput. +- [ ] Run at least a five-minute persistent-worker soak with repeated requests, + changing/released configs/providers, cancellations, contention and worker + replacement. Sample in-load process RSS and lifecycle logs; do not mistake a + replacement PID for evidence that the old worker retained bounded memory. +- [ ] Establish unbound behavior/performance first. Report Runwire-bound results + separately and verify useful measured scheduling or throughput behavior within + the same correctness and resource budgets. Resolve a failing result within + this scope or report an explicit acceptance blocker; do not silently defer + the included integration or claim a gain that measurements do not support. +- [ ] Run exact-final-commit hosted QA, stable/lowest dependency lanes, static/ + security checks, supported runtimes, process coordination, documentation checks, + clean `--no-dev` installation, release guard and relevant host acceptance. +- [ ] Tag only after applicable required gates pass. Keep implementation readiness, + CI readiness, performance certification and published release/tag status separate. + +## Version recommendation and scope + +Target **6.0.0** for all sections A–G and the final gates. The expanded plan +includes previously optional delivery work and public behavior corrections. +Keep consumer adoption of Runwire, injected clocks and upstream-compatible +formats explicit. Preserve existing stored-ID decoding and named arguments. +Raise the production minimum to PHP 8.4 as requested; keep Runwire optional +despite the aligned PHP requirement. + +- [ ] Publish a 5.x → 6.0 migration guide covering mixed-ID ordering, immutable + epochs, secure sequence-state locations, wait budgets, explicit format/lease + selection, the PHP 8.4 minimum, optional dependency installation and + passed-instance composition. +- [ ] Inventory public call signatures and defaults against tag `5.0`; verify + positional and named argument use, helpers, facade calls and value objects. + Document every intentional major change and keep unrelated contracts stable. +- [ ] Add consumer fixtures for old stored IDs, old configs/helpers, direct and + intermediary Runwire forwarding, and applications without optional packages. +- [ ] Require completion or an explicit measured acceptance decision for every + included item. A release is blocked while any required implementation, + compatibility, quality, integration or performance gate remains unresolved. + +A **5.0.1** security/correctness hotfix can precede 6.0 if urgent fixes must ship +earlier. It is a scoped subset and does not replace completion of this full plan. +Such a separate 5.x hotfix would preserve that line's existing PHP requirement. +Do not ship the PHP minimum increase or other intentional breaking public +behavior under a 5.x patch/minor version. The requested complete next release +targets **6.0.0 with PHP 8.4+**. From 0e437eb8e7a46d284e8f025639f4ac7e9958329c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:13:00 +0600 Subject: [PATCH 002/107] chore(release): establish UID 6.0 runtime and QA baseline --- .github/workflows/security-standards.yml | 2 +- README.md | 3 ++- composer.json | 5 +++-- docs/compatibility.rst | 3 ++- docs/installation.rst | 3 ++- docs/uid-review-and-release-plan.md | 6 +++--- tests/FilesystemSequenceV5Test.php | 10 ++++------ tests/ForkSafetyTest.php | 5 ++--- 8 files changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index fb4c037..e8fccac 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -17,7 +17,7 @@ jobs: fail_on_skipped_tests: true integration_services: '[]' service_topologies: '{}' - php_extensions: '["bcmath"]' + php_extensions: '["ctype", "pcntl"]' permissions: security-events: write actions: read diff --git a/README.md b/README.md index e2810f3..791106d 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ All-in-one unique ID toolkit for PHP. ## Requirements -- PHP `>=8.2` +- PHP `>=8.4` - A 64-bit PHP runtime +- PHP ctype extension ## Installation diff --git a/composer.json b/composer.json index 92e9d72..9b4af6a 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,9 @@ } ], "require": { - "php": "^8.2", - "php-64bit": "^8.2" + "php": "^8.4", + "php-64bit": "^8.4", + "ext-ctype": "*" }, "require-dev": { "infocyph/phpforge": "dev-main@dev", diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 614790d..7c75299 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -61,6 +61,7 @@ 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. - No BCMath dependency. - PSR-16 is optional and needed only for the PSR simple-cache sequence provider. diff --git a/docs/installation.rst b/docs/installation.rst index b9f99de..bd69201 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,8 +4,9 @@ Installation Requirements ------------ -- PHP 8.2 or newer +- PHP 8.4 or newer - A 64-bit PHP runtime +- PHP ctype extension - Composer BCMath is not required. PSR-16 is optional and is used only when selecting the diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index bd5ff49..57241da 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -215,12 +215,12 @@ verify codecs and protocol envelopes rather than only self-round trips. owning package. Do not edit `vendor/`, remove the requested checks, add baselines or suppress errors. Refresh UID's development resolution once that fix is available; verify the same detector and intended rules actually execute. -- [ ] Replace skip directives with clear prerequisite assertions for the +- [x] Replace skip directives with clear prerequisite assertions for the designated process-test environment and supply `pcntl`/process support there. Keep meaningful single-process coverage for other platforms. Any suite split must be an explicit portability design; the process suite remains a required release lane and is never hidden to satisfy the scanner. -- [ ] Set Composer runtime requirements to `php: ^8.4` and `php-64bit: ^8.4` +- [x] Set Composer runtime requirements to `php: ^8.4` and `php-64bit: ^8.4` for the next release. Update installation, requirements and compatibility docs together. PHP 8.2/8.3 support ends with the 5.x line; document the upgrade path. - [ ] Verify production source and tooling on real PHP 8.4 and PHP 8.5 in stable @@ -228,7 +228,7 @@ verify codecs and protocol envelopes rather than only self-round trips. versions as they become available; do not claim PHP 9 compatibility from a lower-bound requirement alone. Require platform checks on clean production installs and do not use Composer platform emulation as execution evidence. -- [ ] Declare mandatory ctype support or remove that dependency with equivalent, +- [x] Declare mandatory ctype support or remove that dependency with equivalent, measured validation. Keep PSR-16 optional and production installs free of tooling. - [ ] Address abandoned dev-package usage through PHPForge/PHPBench ownership; do not substitute a new UID production dependency to fix a tooling concern. diff --git a/tests/FilesystemSequenceV5Test.php b/tests/FilesystemSequenceV5Test.php index b29a04c..6580871 100644 --- a/tests/FilesystemSequenceV5Test.php +++ b/tests/FilesystemSequenceV5Test.php @@ -65,9 +65,8 @@ }); 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'); - } + 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); @@ -112,9 +111,8 @@ }); 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/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)); From 00e31cfa9aebbbd753a1533cd0ee2b460474978b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:19:16 +0600 Subject: [PATCH 003/107] fix(uid): correct ULID state and identifier ordering --- src/IdComparator.php | 7 ++++++- src/NanoID.php | 2 +- src/OpaqueId.php | 6 +++++- src/TBSL.php | 2 +- src/ULID.php | 46 ++++++++++++++++++++++++++++++++++---------- 5 files changed, 49 insertions(+), 14 deletions(-) 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/NanoID.php b/src/NanoID.php index 75df183..5aad38e 100644 --- a/src/NanoID.php +++ b/src/NanoID.php @@ -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/TBSL.php b/src/TBSL.php index a8fdfa8..c093ba0 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -86,7 +86,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); } /** diff --git a/src/ULID.php b/src/ULID.php index dafccde..8d2a83f 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -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) { @@ -168,7 +170,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 +238,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 = ''; From 014801979ce54cb4a255d6a21bf85727c01bc3eb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:20:35 +0600 Subject: [PATCH 004/107] fix(uid): enforce UUID and numeric boundary contracts --- docs/uid-review-and-release-plan.md | 14 ++++----- src/Randflake.php | 6 ++++ src/Snowflake.php | 17 ++++++++-- src/Sonyflake.php | 17 ++++++++-- src/UUID.php | 49 ++++++++++------------------- tests/Uid6RegressionTest.php | 47 +++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 44 deletions(-) create mode 100644 tests/Uid6RegressionTest.php diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index 57241da..b845468 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -173,29 +173,29 @@ durable backend where that guarantee is required. ### B. Generator, validation and utility correctness -- [ ] Separate ULID random-mode work from its monotonic state. Make overflow +- [x] Separate ULID random-mode work from its monotonic state. Make overflow state terminal for that timestamp; use a non-mutating overflow decision or commit a new tail only after successful increment. Check timestamp range again after any wait. Cover alternating modes and repeated calls after exceptions. -- [ ] Increment only UUIDv7's usable 74 random bits, carrying across `rand_b` +- [x] Increment only UUIDv7's usable 74 random bits, carrying across `rand_b` and `rand_a` while keeping version/variant fixed. Define full exhaustion and explicit timestamp behavior; preserve existing timestamp/output contracts. -- [ ] Require exact end-of-input and protocol widths for R06. Align validation, +- [x] Require exact end-of-input and protocol widths for R06. Align validation, parse and byte conversion. Preserve intentionally supported UUID input forms; do not turn every normalization helper into an unrelated strictness migration. -- [ ] Revalidate Randflake lease, timestamp lifetime and rollback conditions on +- [x] Revalidate Randflake lease, timestamp lifetime and rollback conditions on every resampled retry before consuming another allocation. Retain UID's documented inclusive lease end in a compatible release. -- [ ] Reject decoded numeric IDs outside each family's signed/non-negative +- [x] Reject decoded numeric IDs outside each family's signed/non-negative domain. Test zero, maximum valid value, first invalid value, all-`ff` bytes and every supported base, plus value-object construction. -- [ ] Establish a total mixed-ID order for R11, for example numeric values first, +- [x] Establish a total mixed-ID order for R11, for example numeric values first, numeric comparison within that group, and lexical comparison within the text group. Test transitivity and shuffled input permutations. Numeric-only and text-only ordering remain stable. Review changes to previously ambiguous mixed ordering against consumers before release; if its pairwise contract must be preserved, add explicit modes and schedule the default correction for a major. -- [ ] Correct the PHP GUID brace fallback and test normalization/round trips. +- [x] Correct the PHP GUID brace fallback and test normalization/round trips. - [ ] Keep provider-instance state weakly associated with the actual provider; replace Sonyflake's reusable object-ID keys. Bound reservation/state metadata without resetting live uniqueness or rollback guards. Do not retain empty diff --git a/src/Randflake.php b/src/Randflake.php index 081603b..304d292 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -262,6 +262,12 @@ private static function generateInternal( $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); } catch (SequenceTimestampException $exception) { $now = time(); + if ($now < $leaseStart || $now > $leaseEnd) { + throw new RandflakeException('randflake: invalid lease, lease expired or not started yet', 0, $exception); + } + if ($now > self::MAX_TIMESTAMP) { + throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime', 0, $exception); + } if ($now < $exception->lastTimestamp) { throw new RandflakeException( 'randflake: timestamp consistency violation, the current time is less than the persisted time', diff --git a/src/Snowflake.php b/src/Snowflake.php index 1f46468..03a0e68 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -201,22 +201,35 @@ 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 assertDecodedId(string $id): string + { + if (!self::isValid($id)) { + throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); + } + + return $id; } private static function encodeNumericBytes(string $id): string diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 4b8b002..4114299 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -40,10 +40,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 +55,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); } /** @@ -179,6 +183,15 @@ private static function decodeNumeric(callable $operation, ?string $customMessag } } + private static function assertDecodedId(string $id): string + { + if (!self::isValid($id)) { + throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); + } + + return $id; + } + /** * Calculates the elapsed time in 10ms units. */ diff --git a/src/UUID.php b/src/UUID.php index 00fefc9..7d83e83 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -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 = hexdec($tail[$index]); + $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'); @@ -800,6 +779,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/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); +}); From 6a4b7df272720283335e83c9619f95f798fb9c6c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:22:35 +0600 Subject: [PATCH 005/107] fix(sequence): harden filesystem allocation state --- docs/uid-review-and-release-plan.md | 6 +- src/Sequence/FilesystemSequenceProvider.php | 29 +++++--- src/Support/FileLock.php | 76 ++++++++++++++++++++- tests/SequenceSafetyTest.php | 47 +++++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/SequenceSafetyTest.php diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index b845468..a9ae4b6 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -137,17 +137,17 @@ No claim of cryptographic certification is made for a full-library code review. ### A. Filesystem and authoritative allocation safety -- [ ] Reproduce R01 using private fixtures, then protect the existing lock/state +- [x] Reproduce R01 using private fixtures, then protect the existing lock/state owner against symlinks, non-regular files, unsafe ownership and precreation. Use an application-owned restricted directory where possible. Check file identity and ownership on the opened handle; a path check alone leaves a race. Never open an unverified target with truncating writes. -- [ ] Preserve a stable lock inode while coordinating writers. Renaming a state +- [x] Preserve a stable lock inode while coordinating writers. Renaming a state file underneath locks can let writers lock different inodes. - [ ] If secure default storage changes location, provide a coordinated migration that preserves sequence high-water marks. Mixed old/new paths or independent empty stores must not create two allocation authorities for the same domain. -- [ ] Fix R08 with integer-safe bounds checked before increment/reservation, +- [x] Fix R08 with integer-safe bounds checked before increment/reservation, including exhaustion, maximum allocation, cached-next and write failures. - [ ] For R02, define shared allocation state as authoritative, non-expiring and non-evicting while its timestamp can still be emitted. Require appropriately diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 6009a9d..9a3ff26 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -58,7 +58,11 @@ public function next(string $type, int $machineId, int $timestamp): int && $reservation['next'] <= $reservation['end'] ) { $allocation = $reservation['next']; - $this->reservations[$fileLocation]['next'] = $allocation + 1; + if ($allocation === $reservation['end']) { + unset($this->reservations[$fileLocation]); + } else { + $this->reservations[$fileLocation]['next'] = $allocation + 1; + } return $allocation; } @@ -76,19 +80,28 @@ public function next(string $type, int $machineId, int $timestamp): int 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; + $reservedEnd = $allocation + $reservationOffset; $state = $timestamp . ',' . $reservedEnd; $this->writeState($handle, $state, $oldLength); - $this->reservations[$fileLocation] = [ - 'timestamp' => $timestamp, - 'next' => $allocation + 1, - 'end' => $reservedEnd, - ]; + if ($this->reservationSize > 1) { + 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, + ]; + } return $allocation; } finally { diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 2daee3e..c48f0bf 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -18,7 +18,7 @@ public static function acquire( string $openErrorMessage, string $lockErrorMessage, ) { - ($handle = fopen($path, 'c+')) || throw new FileLockException($openErrorMessage); + $handle = self::openVerified($path, $openErrorMessage); if ($timeoutMicros === null) { if (flock($handle, LOCK_EX)) { @@ -50,4 +50,78 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } + + /** + * @return resource + * @throws FileLockException + */ + private static function openVerified(string $path, string $errorMessage) + { + $before = @lstat($path); + $created = false; + if ($before === false) { + $handle = @fopen($path, 'x+b'); + if (is_resource($handle)) { + $created = true; + @chmod($path, 0600); + } else { + $before = @lstat($path); + $handle = $before === false ? false : @fopen($path, 'r+b'); + } + } else { + self::assertSafeMetadata($before, $errorMessage); + $handle = @fopen($path, 'r+b'); + } + + if (!is_resource($handle)) { + throw new FileLockException($errorMessage); + } + + try { + $after = fstat($handle); + $pathState = @lstat($path); + if ($after === false || $pathState === false) { + throw new FileLockException($errorMessage); + } + + self::assertSafeMetadata($after, $errorMessage); + self::assertSafeMetadata($pathState, $errorMessage); + if ( + isset($after['dev'], $after['ino'], $pathState['dev'], $pathState['ino']) + && ($after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino']) + ) { + throw new FileLockException($errorMessage); + } + + if (!$created && $before !== false && isset($before['dev'], $before['ino'], $after['dev'], $after['ino'])) { + if ($before['dev'] !== $after['dev'] || $before['ino'] !== $after['ino']) { + throw new FileLockException($errorMessage); + } + } + + return $handle; + } catch (\Throwable $exception) { + fclose($handle); + throw $exception; + } + } + + /** + * @param array $metadata + * @throws FileLockException + */ + private static function assertSafeMetadata(array $metadata, string $errorMessage): void + { + $mode = $metadata['mode'] ?? null; + if (!is_int($mode) || ($mode & 0170000) !== 0100000) { + throw new FileLockException($errorMessage); + } + + if (function_exists('posix_geteuid')) { + $uid = $metadata['uid'] ?? null; + if (!is_int($uid) || $uid !== posix_geteuid()) { + throw new FileLockException($errorMessage); + } + } + } } diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php new file mode 100644 index 0000000..1cdbd12 --- /dev/null +++ b/tests/SequenceSafetyTest.php @@ -0,0 +1,47 @@ +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 { + @unlink($link); + @unlink($target); + @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 { + @unlink($state); + @rmdir($directory); + } +}); From 70ed0bcaeb13b7c2b17ff719e31a8e30a36e73e1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:24:54 +0600 Subject: [PATCH 006/107] fix(state): fail closed on allocation regression --- docs/uid-review-and-release-plan.md | 8 +++--- src/Configuration/ResolvesCustomEpoch.php | 12 ++++---- src/Configuration/SnowflakeConfig.php | 5 +++- src/Configuration/SonyflakeConfig.php | 5 +++- src/Randflake.php | 28 ++++++++++++------- .../PsrSimpleCacheSequenceProvider.php | 24 +++++++++++++++- src/Sonyflake.php | 12 ++++---- tests/SequenceProviderTest.php | 24 ++++++++++++++++ 8 files changed, 89 insertions(+), 29 deletions(-) diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index a9ae4b6..39d4fc7 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -155,7 +155,7 @@ No claim of cryptographic certification is made for a full-library code review. those guarantees. Document backend default TTL, clearing, failover, restoration, machine ownership and restart requirements. Fail closed when known state is lost or allocations regress; a local guard alone is not a distributed fix. -- [ ] Add repeated-allocation detection to Randflake's stable provider/domain +- [x] Add repeated-allocation detection to Randflake's stable provider/domain state so ordinary same-instance state loss cannot emit a known duplicate. Cover restart/new-provider limitations explicitly. A durable external provider can use the existing `SequenceProviderInterface`/callback boundary. @@ -196,7 +196,7 @@ durable backend where that guarantee is required. ordering against consumers before release; if its pairwise contract must be preserved, add explicit modes and schedule the default correction for a major. - [x] Correct the PHP GUID brace fallback and test normalization/round trips. -- [ ] Keep provider-instance state weakly associated with the actual provider; +- [x] Keep provider-instance state weakly associated with the actual provider; replace Sonyflake's reusable object-ID keys. Bound reservation/state metadata without resetting live uniqueness or rollback guards. Do not retain empty reservation bookkeeping for size 1 without a demonstrated need. @@ -235,7 +235,7 @@ verify codecs and protocol envelopes rather than only self-round trips. - [ ] Fix R14 and publish explicit UID-specific Sonyflake/Randflake compatibility notes. Include identifier selection, collision budgets for short configurable outputs, unique storage constraints and independent authorization requirements. -- [ ] Redact Randflake secret-bearing callable parameters with +- [x] Redact Randflake secret-bearing callable parameters with `#[SensitiveParameter]`; consider configuration-object exposure separately. Attribute redaction does not hide a public property or authorize logging it. @@ -388,7 +388,7 @@ clock, cancellation and worker tests execute for both modes where applicable. - [ ] Replace TBSL's tight rollback/rollover spin with bounded waiting. Use the passed scope's cooperative sleep where available and a bounded native wait otherwise. Measure short normal rollover behavior before selecting intervals. -- [ ] Normalize custom epochs at configuration construction into immutable scalar +- [x] Normalize custom epochs at configuration construction into immutable scalar milliseconds or immutable date values, and reuse the normalized result. Mutating a caller-owned `DateTime` later must not change an existing ID domain. Validate supported epoch/range boundaries and preserve parser metadata. 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..a136890 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -15,6 +15,8 @@ private ?Closure $nodeResolver; + public ?int $customEpoch; + /** * @param callable():mixed|null $nodeResolver * @param DateTimeInterface|int|null $customEpoch Epoch in milliseconds or a date-time value. @@ -23,11 +25,12 @@ 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, ) { $this->nodeResolver = $nodeResolver ? $nodeResolver(...) : null; + $this->customEpoch = self::normalizeEpoch($customEpoch); } /** diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index 03aadab..e2474cf 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -10,6 +10,8 @@ final readonly class SonyflakeConfig { + public ?int $customEpoch; + use ResolvesCustomEpoch; use ResolvesMachineId; @@ -19,10 +21,11 @@ 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, ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; + $this->customEpoch = self::normalizeEpoch($customEpoch); } } diff --git a/src/Randflake.php b/src/Randflake.php index 304d292..5139ef0 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -102,7 +102,7 @@ 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, @@ -116,7 +116,7 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, str /** * @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)); } @@ -139,7 +139,7 @@ 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 + public static function inspect(string $id, #[\SensitiveParameter] string $secret): array { if (!self::isValid($id)) { throw new RandflakeException('randflake: invalid id'); @@ -161,7 +161,7 @@ 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 + public static function inspectString(string $id, #[\SensitiveParameter] string $secret): array { return self::inspect(self::decodeString($id), $secret); } @@ -177,7 +177,7 @@ 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 + public static function parse(string $id, #[\SensitiveParameter] string $secret): array { if (!self::isValid($id)) { throw new RandflakeException('randflake: invalid id'); @@ -199,7 +199,7 @@ 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 + public static function parseString(string $id, #[\SensitiveParameter] string $secret): array { return self::parse(self::decodeString($id), $secret); } @@ -234,7 +234,7 @@ private static function generateInternal( int $nodeId, int $leaseStart, int $leaseEnd, - string $secret, + #[\SensitiveParameter] string $secret, ?SequenceProviderInterface $sequenceProvider, ): string { self::validateNode($nodeId); @@ -253,7 +253,8 @@ private static function generateInternal( throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); } - $lastTimestamp = $providerState[$nodeId] ?? null; + $last = $providerState[$nodeId] ?? null; + $lastTimestamp = $last['timestamp'] ?? null; if ($lastTimestamp !== null && $now < $lastTimestamp) { throw new RandflakeException('randflake: timestamp consistency violation, the current time is less than the last time'); } @@ -283,13 +284,20 @@ private static function generateInternal( } $sequence = $sequenceValue - 1; + 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)", ); } - $providerState[$nodeId] = $now; + $providerState[$nodeId] = ['timestamp' => $now, 'sequence' => $sequence]; $plain = self::packPayload($now, $nodeId, $sequence); $cipher = self::permute($plain, $secret, false); @@ -458,7 +466,7 @@ 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/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 037041f..d4e1c35 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -16,6 +16,9 @@ { private ?Closure $synchronizer; + /** @var \ArrayObject */ + private \ArrayObject $observedState; + /** * @param callable(string, callable():int):mixed|null $synchronizer */ @@ -31,6 +34,7 @@ public function __construct( } $this->synchronizer = $synchronizer ? $synchronizer(...) : null; + $this->observedState = new \ArrayObject(); } /** @@ -114,6 +118,11 @@ private function key(string $type, int $machineId): string private function nextFromCacheState(string $key, int $timestamp): int { $state = $this->cache->get($key); + $observed = $this->observedState[$key] ?? null; + if ($state === null && $observed !== null) { + throw new FileLockException('Cached sequence state was lost for key: ' . $key); + } + $sequence = 1; if ($state !== null) { if ( @@ -127,6 +136,16 @@ private function nextFromCacheState(string $key, int $timestamp): int throw new FileLockException('Cached sequence state is malformed for key: ' . $key); } + if ( + $observed !== null + && ( + $state['timestamp'] < $observed['timestamp'] + || ($state['timestamp'] === $observed['timestamp'] && $state['sequence'] < $observed['sequence']) + ) + ) { + throw new FileLockException('Cached sequence state regressed for key: ' . $key); + } + if ($state['timestamp'] > $timestamp) { throw new SequenceTimestampException( $state['timestamp'], @@ -144,10 +163,13 @@ private function nextFromCacheState(string $key, int $timestamp): int } } - if (!$this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence])) { + $nextState = ['timestamp' => $timestamp, 'sequence' => $sequence]; + if (!$this->cache->set($key, $nextState, null)) { throw new FileLockException('Failed to persist sequence state for key: ' . $key); } + $this->observedState[$key] = $nextState; + return $sequence; } } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 4114299..88ef682 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -30,8 +30,8 @@ final class Sonyflake private const TIMESTAMP_BITS = 39; - /** @var array */ - private static array $lastWallTimeByDomain = []; + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastWallTimeByProvider = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal. @@ -249,9 +249,11 @@ private static function generateInternal( } $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); + self::$lastWallTimeByProvider ??= new \WeakMap(); + $providerState = self::$lastWallTimeByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); $currentTime = (int) floor(microtime(true) * 1000); - $domainKey = $startTimestamp . ':' . $machineId . ':' . spl_object_id($resolvedSequenceProvider); - $lastWallTime = self::$lastWallTimeByDomain[$domainKey] ?? 0; + $domainKey = $startTimestamp . ':' . $machineId; + $lastWallTime = $providerState[$domainKey] ?? 0; if ($currentTime < $lastWallTime) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); @@ -298,7 +300,7 @@ private static function generateInternal( $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); } - self::$lastWallTimeByDomain[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); + $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); self::ensureEffectiveRuntime($elapsedTime); diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php index 5a03d6c..eb3e528 100644 --- a/tests/SequenceProviderTest.php +++ b/tests/SequenceProviderTest.php @@ -224,3 +224,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); +}); From 78e6238ea0104703c0ecd2c3e744a1fd9dae318e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:27:36 +0600 Subject: [PATCH 007/107] style(uid): align constants with PHP 8.4 rules --- src/CUID2.php | 4 ++-- src/DeterministicId.php | 6 +++--- src/KSUID.php | 6 +++--- src/NanoID.php | 2 +- src/Randflake.php | 16 ++++++++-------- src/RandomId.php | 2 +- src/Sequence/FilesystemSequenceProvider.php | 4 ++-- src/UUID.php | 14 +++++++------- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/CUID2.php b/src/CUID2.php index 2292bd9..aaf450b 100644 --- a/src/CUID2.php +++ b/src/CUID2.php @@ -10,9 +10,9 @@ final class CUID2 { - private const INITIAL_COUNTER_MAX = 476_782_367; + private const int INITIAL_COUNTER_MAX = 476_782_367; - private const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; + private const string LETTERS = 'abcdefghijklmnopqrstuvwxyz'; private static int $counter; 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/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 5aad38e..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. diff --git a/src/Randflake.php b/src/Randflake.php index 5139ef0..f8760c8 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -22,21 +22,21 @@ 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 static ?\WeakMap $lastTimestampByProvider = null; 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/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 9a3ff26..6f22867 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -11,9 +11,9 @@ 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_SEQUENCE_STATE_BYTES = 64; private readonly string $baseDirectory; diff --git a/src/UUID.php b/src/UUID.php index 7d83e83..b0c53b2 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 . '}'; } /** From c4175936b11d5b780d5b7995ee71fd0742de9376 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:28:30 +0600 Subject: [PATCH 008/107] style(uid): complete PHP 8.4 constant typing --- src/Snowflake.php | 28 +++++++++++++++------------- src/Sonyflake.php | 26 ++++++++++++++------------ src/Support/BaseEncoder.php | 4 ++-- src/Support/DecimalBytes.php | 2 +- src/Support/TypeIdCodec.php | 2 +- src/ULID.php | 10 +++++----- src/XID.php | 2 +- 7 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/Snowflake.php b/src/Snowflake.php index 03a0e68..ed05bd8 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -22,15 +22,15 @@ 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 SEQUENCE_BITS = 12; - private const TIMESTAMP_BITS = 41; + private const int TIMESTAMP_BITS = 41; - private const WORKER_BITS = 5; + private const int WORKER_BITS = 5; /** @var \WeakMap>|null */ private static ?\WeakMap $lastStateByProvider = null; @@ -166,6 +166,16 @@ 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 */ @@ -223,14 +233,6 @@ private static function decodeNumericBytes(string $bytes): string return self::assertDecodedId($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; - } private static function encodeNumericBytes(string $id): string { diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 88ef682..a6a55a0 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -22,13 +22,13 @@ 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 SEQUENCE_BITS = 8; - private const TIMESTAMP_BITS = 39; + private const int TIMESTAMP_BITS = 39; /** @var \WeakMap>|null */ private static ?\WeakMap $lastWallTimeByProvider = null; @@ -170,6 +170,16 @@ public static function toBytes(string $id): string ); } + + private static function assertDecodedId(string $id): string + { + if (!self::isValid($id)) { + throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); + } + + return $id; + } + /** * @param callable():string $operation * @throws SonyflakeException @@ -183,14 +193,6 @@ private static function decodeNumeric(callable $operation, ?string $customMessag } } - private static function assertDecodedId(string $id): string - { - if (!self::isValid($id)) { - throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); - } - - return $id; - } /** * Calculates the elapsed time in 10ms units. diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 87299b3..c4af6b7 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,7 +17,7 @@ final class BaseEncoder 62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', ]; - private const MAX_BYTE_LENGTH = 1024; + private const int MAX_BYTE_LENGTH = 1024; /** * Decodes one of supported bases (16/32/36/58/62) into bytes. diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php index b433fb1..8a4706e 100644 --- a/src/Support/DecimalBytes.php +++ b/src/Support/DecimalBytes.php @@ -6,7 +6,7 @@ final class DecimalBytes { - private const MAX_BYTE_LENGTH = 1024; + private const int MAX_BYTE_LENGTH = 1024; public static function fromBytes(string $bytes): string { 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/ULID.php b/src/ULID.php index 8d2a83f..3a62b43 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; 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; From 3e0c999d32a78d20a9abe45b0830b0b582df0e31 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:31:21 +0600 Subject: [PATCH 009/107] fix(qa): resolve UID regression and class ordering From b20c6733189437878589e841c1d01dbc8433ea73 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:33:51 +0600 Subject: [PATCH 010/107] feat(runtime): add bounded generation context --- composer.json | 4 ++ src/Runtime/GenerationContext.php | 70 ++++++++++++++++++++++++++ src/Runtime/RunwireBinding.php | 83 +++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/Runtime/GenerationContext.php create mode 100644 src/Runtime/RunwireBinding.php diff --git a/composer.json b/composer.json index 9b4af6a..da0c90f 100644 --- a/composer.json +++ b/composer.json @@ -37,12 +37,16 @@ }, "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/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php new file mode 100644 index 0000000..91f8b74 --- /dev/null +++ b/src/Runtime/GenerationContext.php @@ -0,0 +1,70 @@ +runwire?->assertActive(); + } + + public function nowMicroseconds(): int + { + $this->assertActive(); + + if ($this->clock === null) { + return (int) floor(microtime(true) * 1_000_000); + } + + return (int) $this->clock->now()->format('Uu'); + } + + public function nowMilliseconds(): int + { + return intdiv($this->nowMicroseconds(), 1_000); + } + + public function nowSeconds(): int + { + return intdiv($this->nowMicroseconds(), 1_000_000); + } + + public function waitDeadlineNanoseconds(): int + { + $deadline = hrtime(true) + ($this->waitTimeoutMicros * 1_000); + $runwireDeadline = $this->runwire?->deadlineNanoseconds(); + + return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline); + } + + public function sleepMicroseconds(int $microseconds): void + { + $this->assertActive(); + if ($this->runwire !== null) { + $this->runwire->sleep($microseconds / 1_000_000); + $this->assertActive(); + + return; + } + + usleep($microseconds); + } +} diff --git a/src/Runtime/RunwireBinding.php b/src/Runtime/RunwireBinding.php new file mode 100644 index 0000000..d6c8074 --- /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(); + } +} From df114877d42c98c9d379b6c4e4e60b5f4c86ece8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:34:23 +0600 Subject: [PATCH 011/107] fix(runtime): allow shared Runwire scope probe --- src/Runtime/RunwireBinding.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Runtime/RunwireBinding.php b/src/Runtime/RunwireBinding.php index d6c8074..810eb9d 100644 --- a/src/Runtime/RunwireBinding.php +++ b/src/Runtime/RunwireBinding.php @@ -11,14 +11,14 @@ use Infocyph\Runwire\RuntimeContext; use LogicException; -final readonly class RunwireBinding +final class RunwireBinding { private static ?TaskLocal $scopeProbe = null; public function __construct( - public RuntimeContext $runtime, - public ?RequestContext $request = null, - public ?CoroutineScope $scope = null, + public readonly RuntimeContext $runtime, + public readonly ?RequestContext $request = null, + public readonly ?CoroutineScope $scope = null, ) { $this->assertActive(); } From 559cb28087eea0fffb817a62fa05107c1b974137 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:35:10 +0600 Subject: [PATCH 012/107] feat(config): pass generation runtime explicitly --- src/Configuration/RandflakeConfig.php | 4 +++- src/Configuration/SnowflakeConfig.php | 2 ++ src/Configuration/SonyflakeConfig.php | 2 ++ src/Configuration/TBSLConfig.php | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index 0cbf577..bb851b9 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -4,6 +4,7 @@ namespace Infocyph\UID\Configuration; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class RandflakeConfig @@ -12,7 +13,8 @@ public function __construct( public int $nodeId, public int $leaseStart, public int $leaseEnd, - public string $secret, + #[\SensitiveParameter] public string $secret, public ?SequenceProviderInterface $sequenceProvider = null, + public ?GenerationContext $runtime = null, ) {} } diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index a136890..7eba0d9 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -7,6 +7,7 @@ use Closure; use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SnowflakeConfig @@ -28,6 +29,7 @@ public function __construct( 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 e2474cf..9380c4e 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -6,6 +6,7 @@ use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SonyflakeConfig @@ -24,6 +25,7 @@ public function __construct( DateTimeInterface|int|null $customEpoch = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, + public ?GenerationContext $runtime = null, ) { $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; } From e3915d7c18d375840af7ea3bb36ca49849859621 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:36:18 +0600 Subject: [PATCH 013/107] feat(runtime): bound coordinated millisecond waits --- src/Snowflake.php | 40 ++++++++++++++++++++++++------- src/Sonyflake.php | 61 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/Snowflake.php b/src/Snowflake.php index ed05bd8..f9923e6 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; @@ -30,6 +31,8 @@ final class Snowflake private const int TIMESTAMP_BITS = 41; + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + private const int WORKER_BITS = 5; /** @var \WeakMap>|null */ @@ -70,6 +73,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string $workerId, self::getStartTimeStamp(), ClockBackwardPolicy::WAIT, + runtime: null, ); } @@ -89,6 +93,7 @@ public static function generateWithConfig(SnowflakeConfig $config): string $customEpoch ?? self::getStartTimeStamp(), $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, ); } @@ -255,10 +260,11 @@ 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); + $currentTime = self::nowMilliseconds($runtime); self::assertTimestampRange($currentTime, $startTimestamp); $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); @@ -280,6 +286,7 @@ private static function generateInternal( $clockBackwardPolicy, $resolvedSequenceProvider, $sequenceType, + $runtime, ); $lastState = $providerState[$stateKey] ?? null; @@ -294,7 +301,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; @@ -338,6 +345,7 @@ private static function nextSequenceAtValidTimestamp( ClockBackwardPolicy $clockBackwardPolicy, SequenceProviderInterface $sequenceProvider, string $sequenceType, + ?GenerationContext $runtime, ): array { while (true) { try { @@ -351,7 +359,7 @@ private static function nextSequenceAtValidTimestamp( ); } - $currentTime = self::waitUntil($exception->lastTimestamp); + $currentTime = self::waitUntil($exception->lastTimestamp, $runtime); self::assertTimestampRange($currentTime, $startTimestamp); continue; @@ -365,7 +373,7 @@ private static function nextSequenceAtValidTimestamp( return [$currentTime, $allocation - 1]; } - $currentTime = self::waitUntil($currentTime + 1); + $currentTime = self::waitUntil($currentTime + 1, $runtime); self::assertTimestampRange($currentTime, $startTimestamp); } } @@ -383,12 +391,26 @@ 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 nowMilliseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); + } + + 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 a6a55a0..344cb68 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -11,6 +11,7 @@ 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; @@ -30,6 +31,8 @@ final class Sonyflake private const int TIMESTAMP_BITS = 39; + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + /** @var \WeakMap>|null */ private static ?\WeakMap $lastWallTimeByProvider = null; @@ -76,6 +79,7 @@ public static function generate(int $machineId = 0): string $machineId, self::getStartTimeStamp(), ClockBackwardPolicy::WAIT, + runtime: null, ); } @@ -91,6 +95,7 @@ public static function generateWithConfig(SonyflakeConfig $config): string $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, ); } @@ -244,6 +249,7 @@ private static function generateInternal( int $startTimestamp, ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, + ?GenerationContext $runtime = null, ): string { $maxMachineID = -1 ^ (-1 << self::MACHINE_BITS); if ($machineId < 0 || $machineId > $maxMachineID) { @@ -253,7 +259,7 @@ private static function generateInternal( $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); self::$lastWallTimeByProvider ??= new \WeakMap(); $providerState = self::$lastWallTimeByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); - $currentTime = (int) floor(microtime(true) * 1000); + $currentTime = self::nowMilliseconds($runtime); $domainKey = $startTimestamp . ':' . $machineId; $lastWallTime = $providerState[$domainKey] ?? 0; if ($currentTime < $lastWallTime) { @@ -261,7 +267,7 @@ private static function generateInternal( throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); } - $currentTime = self::waitUntilWallTime($lastWallTime); + $currentTime = self::waitUntilWallTime($lastWallTime, $runtime); } $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); @@ -285,7 +291,7 @@ private static function generateInternal( ); } - $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp); + $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp, $runtime); continue; } @@ -300,7 +306,7 @@ private static function generateInternal( break; } - $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); + $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp, $runtime); } $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); @@ -324,23 +330,50 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } - private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int + private static function nowMilliseconds(?GenerationContext $runtime): int { - $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); - while ($next <= $elapsedTime) { - usleep(1000); - $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); + } + + 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; } From 8df127bce9f8acb28f2c9cbe2ed4dd5d71ae1e92 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:37:06 +0600 Subject: [PATCH 014/107] feat(runtime): bound TBSL and Randflake timing --- src/Randflake.php | 13 +++++++++++-- src/TBSL.php | 42 ++++++++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index f8760c8..bfe8564 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -10,6 +10,7 @@ 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; @@ -110,6 +111,7 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\ $leaseEnd, $secret, null, + null, ); } @@ -132,6 +134,7 @@ public static function generateWithConfig(RandflakeConfig $config): string $config->leaseEnd, $config->secret, $config->sequenceProvider, + $config->runtime, ); } @@ -236,6 +239,7 @@ private static function generateInternal( int $leaseEnd, #[\SensitiveParameter] string $secret, ?SequenceProviderInterface $sequenceProvider, + ?GenerationContext $runtime, ): string { self::validateNode($nodeId); self::validateLeaseWindow($leaseStart, $leaseEnd); @@ -244,7 +248,7 @@ private static function generateInternal( $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); self::$lastTimestampByProvider ??= new \WeakMap(); $providerState = self::$lastTimestampByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); - $now = time(); + $now = self::nowSeconds($runtime); if ($now < $leaseStart || $now > $leaseEnd) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); } @@ -262,7 +266,7 @@ private static function generateInternal( try { $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); } catch (SequenceTimestampException $exception) { - $now = time(); + $now = self::nowSeconds($runtime); if ($now < $leaseStart || $now > $leaseEnd) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet', 0, $exception); } @@ -305,6 +309,11 @@ private static function generateInternal( return DecimalBytes::fromBytes($cipher); } + private static function nowSeconds(?GenerationContext $runtime): int + { + return $runtime?->nowSeconds() ?? time(); + } + /** * @return array{0:int,1:int,2:int} * @throws RandflakeException diff --git a/src/TBSL.php b/src/TBSL.php index c093ba0..d709d06 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -10,6 +10,7 @@ use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\UIDException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\GetSequence; @@ -18,6 +19,8 @@ final class TBSL { use GetSequence; + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + private static int $lastTimeSequence = 0; /** @@ -58,6 +61,7 @@ public static function generate(int $machineId = 0, bool $sequenced = true): str $machineId, $sequenced, ClockBackwardPolicy::WAIT, + runtime: null, ); } @@ -78,6 +82,7 @@ public static function generateWithConfig(TBSLConfig $config): string $config->sequenced, $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, ); } @@ -160,18 +165,18 @@ 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)); + $timeSequence = self::nowMicroseconds($runtime); if ($timeSequence < self::$lastTimeSequence) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new UIDException('Clock moved backwards while generating TBSL ID'); } - $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence); + $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence, $runtime); } [$timeSequence, $tail] = self::resolveTail( $machineId, @@ -179,6 +184,7 @@ private static function generateInternal( $timeSequence, $clockBackwardPolicy, $sequenceProvider, + $runtime, ); self::$lastTimeSequence = $timeSequence; @@ -210,6 +216,7 @@ private static function resolveTail( int $timeSequence, ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, + ?GenerationContext $runtime = null, ): array { if (!$enableSequence) { return [$timeSequence, substr(bin2hex(random_bytes(3)), 0, 5)]; @@ -227,7 +234,7 @@ private static function resolveTail( ); } - $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp); + $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp, $runtime); continue; } @@ -240,16 +247,31 @@ 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 nowMicroseconds(?GenerationContext $runtime): int { - do { - [$micro, $seconds] = explode(' ', microtime()); - $candidate = (int) ($seconds . substr($micro, 2, 6)); - } while ($candidate <= $last); + return $runtime?->nowMicroseconds() ?? (int) floor(microtime(true) * 1_000_000); + } + + private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int + { + $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; } From c171f3c5fe8e93374eaeb4bbcb27730385a6aced Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:38:17 +0600 Subject: [PATCH 015/107] feat(runtime): make sequence locks cooperative --- src/Sequence/FilesystemSequenceProvider.php | 3 +++ .../PsrSimpleCacheSequenceProvider.php | 3 +++ src/Support/FileLock.php | 18 +++++++++++++++--- src/Support/GetSequence.php | 5 +++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 6f22867..bcd2350 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -6,6 +6,7 @@ use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Support\FileLock; use InvalidArgumentException; @@ -30,6 +31,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(); @@ -72,6 +74,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, + $this->runtime, ); try { diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index d4e1c35..3dddd51 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -7,6 +7,7 @@ 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; @@ -28,6 +29,7 @@ public function __construct( private int $waitTime = 1_000, private int $maxAttempts = 1_000, ?callable $synchronizer = null, + private ?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'); @@ -98,6 +100,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, + $this->runtime, ); } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index c48f0bf..550c1d9 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Support; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Runtime\GenerationContext; final class FileLock { @@ -17,10 +18,11 @@ public static function acquire( ?int $timeoutMicros, string $openErrorMessage, string $lockErrorMessage, + ?GenerationContext $runtime = null, ) { $handle = self::openVerified($path, $openErrorMessage); - if ($timeoutMicros === null) { + if ($timeoutMicros === null && $runtime === null) { if (flock($handle, LOCK_EX)) { return $handle; } @@ -30,7 +32,13 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } - $deadline = hrtime(true) + ($timeoutMicros * 1000); + $effectiveTimeout = $timeoutMicros ?? $runtime?->waitTimeoutMicros ?? 1_000_000; + $deadline = hrtime(true) + ($effectiveTimeout * 1000); + $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); + if ($runtimeDeadline !== null) { + $deadline = min($deadline, $runtimeDeadline); + } + do { $wouldBlock = 0; if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { @@ -43,7 +51,11 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } - usleep(1000); + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } while (hrtime(true) < $deadline); fclose($handle); diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index 0087315..b4a869b 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -7,6 +7,7 @@ use Infocyph\UID\Sequence\CallbackSequenceProvider; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\InMemorySequenceProvider; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\PsrSimpleCacheSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Psr\SimpleCache\CacheInterface; @@ -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, ); } From 84c8dfe083cf8985c264f1a8dbf22e531c698e46 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:39:44 +0600 Subject: [PATCH 016/107] test(runtime): cover bounded waits and cancellation --- tests/RuntimeIntegrationTest.php | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/RuntimeIntegrationTest.php diff --git a/tests/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php new file mode 100644 index 0000000..4a754c5 --- /dev/null +++ b/tests/RuntimeIntegrationTest.php @@ -0,0 +1,72 @@ +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(); +}); From b2ab7c8e5e39307acaab208f68c2621d33dcb4dd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:42:23 +0600 Subject: [PATCH 017/107] feat(sonyflake): add explicit upstream wire format --- src/Configuration/SonyflakeConfig.php | 2 + src/Enums/SonyflakeFormat.php | 12 ++++++ src/Sonyflake.php | 57 +++++++++++++++++++-------- tests/SonyflakeFormatTest.php | 26 ++++++++++++ 4 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 src/Enums/SonyflakeFormat.php create mode 100644 tests/SonyflakeFormatTest.php diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index 9380c4e..f043c25 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -6,6 +6,7 @@ use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Enums\SonyflakeFormat; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; @@ -26,6 +27,7 @@ public function __construct( 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/Enums/SonyflakeFormat.php b/src/Enums/SonyflakeFormat.php new file mode 100644 index 0000000..0197f26 --- /dev/null +++ b/src/Enums/SonyflakeFormat.php @@ -0,0 +1,12 @@ +resolveMachineId(), - $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), + $config->resolveCustomEpochMs() ?? self::getStartTimeStamp($config->format), $config->clockBackwardPolicy, $config->sequenceProvider, $config->runtime, + $config->format, ); } @@ -116,9 +121,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); } /** @@ -127,13 +132,17 @@ 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( @@ -227,8 +236,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); @@ -236,8 +248,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, ]; } @@ -250,6 +266,7 @@ private static function generateInternal( ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, ?GenerationContext $runtime = null, + SonyflakeFormat $format = SonyflakeFormat::UID, ): string { $maxMachineID = -1 ^ (-1 << self::MACHINE_BITS); if ($machineId < 0 || $machineId > $maxMachineID) { @@ -272,7 +289,9 @@ private static function generateInternal( $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); self::ensureEffectiveRuntime($elapsedTime); - $sequenceType = 'sonyflake_' . $startTimestamp; + $sequenceType = $format === SonyflakeFormat::UID + ? 'sonyflake_' . $startTimestamp + : 'sonyflake_upstream_' . $startTimestamp; while (true) { try { @@ -312,17 +331,23 @@ private static function generateInternal( self::ensureEffectiveRuntime($elapsedTime); - return (string) ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) - | ($machineId << self::SEQUENCE_BITS) - | ($sequence)); + return (string) ($format === SonyflakeFormat::UPSTREAM + ? ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) + | ($sequence << self::MACHINE_BITS) + | $machineId) + : ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) + | ($machineId << self::SEQUENCE_BITS) + | $sequence)); } /** * Retrieves the start timestamp. */ - private static function getStartTimeStamp(): int + private static function getStartTimeStamp(SonyflakeFormat $format): int { - return self::DEFAULT_EPOCH; + return $format === SonyflakeFormat::UPSTREAM + ? self::UPSTREAM_DEFAULT_EPOCH + : self::DEFAULT_EPOCH; } private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface 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); +}); From 5a799d7f64213dfa2ad44215f997194054ffda92 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:45:07 +0600 Subject: [PATCH 018/107] feat(randflake): add upstream SPARX64 primitives --- src/Support/SignedDecimal64.php | 98 ++++++++++++++ src/Support/Sparx64.php | 165 +++++++++++++++++++++++ tests/RandflakeUpstreamPrimitiveTest.php | 28 ++++ 3 files changed, 291 insertions(+) create mode 100644 src/Support/SignedDecimal64.php create mode 100644 src/Support/Sparx64.php create mode 100644 tests/RandflakeUpstreamPrimitiveTest.php diff --git a/src/Support/SignedDecimal64.php b/src/Support/SignedDecimal64.php new file mode 100644 index 0000000..fec9fae --- /dev/null +++ b/src/Support/SignedDecimal64.php @@ -0,0 +1,98 @@ + 0) { + throw new InvalidArgumentException('Value is outside the unsigned 64-bit storage domain'); + } + + return strrev(DecimalBytes::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; + } + + $result = chr(48 + $digit - $rightDigit) . $result; + --$leftIndex; + --$rightIndex; + } + + return UnsignedDecimal::normalize($result); + } +} diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php new file mode 100644 index 0000000..e262224 --- /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; + } + + /** @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]), + ]; + } + + /** @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); + } +} diff --git a/tests/RandflakeUpstreamPrimitiveTest.php b/tests/RandflakeUpstreamPrimitiveTest.php new file mode 100644 index 0000000..a4ba7f3 --- /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); +}); From eb38ad4ea8da5223093112c007af88e54aee45d2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:46:23 +0600 Subject: [PATCH 019/107] feat(randflake): define explicit format and lease mode --- src/Configuration/RandflakeConfig.php | 13 +++++++++++++ src/Enums/RandflakeFormat.php | 12 ++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/Enums/RandflakeFormat.php diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index bb851b9..954d769 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -4,6 +4,7 @@ namespace Infocyph\UID\Configuration; +use Infocyph\UID\Enums\RandflakeFormat; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; @@ -16,5 +17,17 @@ public function __construct( #[\SensitiveParameter] public string $secret, public ?SequenceProviderInterface $sequenceProvider = null, public ?GenerationContext $runtime = null, + public RandflakeFormat $format = RandflakeFormat::UID, + public ?int $leaseEndExclusive = null, ) {} + + public function resolveLeaseEndExclusive(): int + { + if ($this->leaseEndExclusive !== null) { + return $this->leaseEndExclusive; + } + + return $this->leaseEnd + 1; + } } + 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 @@ + Date: Tue, 6 Oct 2026 21:47:57 +0600 Subject: [PATCH 020/107] feat(randflake): implement upstream SPARX64 format --- src/Randflake.php | 273 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 226 insertions(+), 47 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index bfe8564..6af1184 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -9,6 +9,7 @@ use Infocyph\UID\Configuration\RandflakeConfig; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\RandflakeException; +use Infocyph\UID\Enums\RandflakeFormat; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\FilesystemSequenceProvider; @@ -17,6 +18,8 @@ use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; +use Infocyph\UID\Support\SignedDecimal64; +use Infocyph\UID\Support\Sparx64; use Infocyph\UID\Support\UnsignedDecimal; final class Randflake @@ -45,17 +48,44 @@ final class Randflake /** @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 = SignedDecimal64::fromLittleEndianBytes(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', + 'randflake: invalid id', 0, $exception, ), @@ -65,20 +95,44 @@ public static function decodeString(string $id): string /** * @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 SignedDecimal64::fromLittleEndianBytes( + strrev(BaseEncoder::decodeToBytes($encoded, $base, 8)), + ); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + } + return NumericConversion::decimalFromBase( $encoded, $base, @@ -90,8 +144,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 SignedDecimal64::fromLittleEndianBytes($bytes); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + } + return NumericConversion::decimalFromBytes( $bytes, 8, @@ -112,6 +176,8 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\ $secret, null, null, + RandflakeFormat::UID, + null, ); } @@ -135,6 +201,10 @@ public static function generateWithConfig(RandflakeConfig $config): string $config->secret, $config->sequenceProvider, $config->runtime, + $config->format, + $config->format === RandflakeFormat::UPSTREAM + ? $config->resolveLeaseEndExclusive() + : null, ); } @@ -142,15 +212,19 @@ public static function generateWithConfig(RandflakeConfig $config): string * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ - public static function inspect(string $id, #[\SensitiveParameter] 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 [ @@ -164,13 +238,22 @@ public static function inspect(string $id, #[\SensitiveParameter] string $secret * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ - public static function inspectString(string $id, #[\SensitiveParameter] 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 SignedDecimal64::isValid($id); + } + return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, '18446744073709551615') <= 0; @@ -180,15 +263,19 @@ public static function isValid(string $id): bool * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ - public static function parse(string $id, #[\SensitiveParameter] 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 [ @@ -202,28 +289,53 @@ public static function parse(string $id, #[\SensitiveParameter] string $secret): * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ - public static function parseString(string $id, #[\SensitiveParameter] 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 SignedDecimal64::toLittleEndianBytes($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), @@ -240,16 +352,19 @@ private static function generateInternal( #[\SensitiveParameter] string $secret, ?SequenceProviderInterface $sequenceProvider, ?GenerationContext $runtime, + RandflakeFormat $format, + ?int $leaseEndExclusive, ): string { self::validateNode($nodeId); - self::validateLeaseWindow($leaseStart, $leaseEnd); + self::validateLeaseWindow($leaseStart, $leaseEnd, $format, $leaseEndExclusive); $secret = self::validateSecret($secret); $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); self::$lastTimestampByProvider ??= new \WeakMap(); $providerState = self::$lastTimestampByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); + $domainKey = $format->value . ':' . $nodeId; $now = self::nowSeconds($runtime); - if ($now < $leaseStart || $now > $leaseEnd) { + if (!self::leaseContains($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive)) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); } @@ -257,17 +372,22 @@ private static function generateInternal( throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); } - $last = $providerState[$nodeId] ?? null; + $last = $providerState[$domainKey] ?? null; $lastTimestamp = $last['timestamp'] ?? null; if ($lastTimestamp !== null && $now < $lastTimestamp) { throw new RandflakeException('randflake: timestamp consistency violation, the current time is less than the last time'); } try { - $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + $sequenceValue = self::sequence( + $now, + $nodeId, + $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream', + $resolvedSequenceProvider, + ); } catch (SequenceTimestampException $exception) { $now = self::nowSeconds($runtime); - if ($now < $leaseStart || $now > $leaseEnd) { + if (!self::leaseContains($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive)) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet', 0, $exception); } if ($now > self::MAX_TIMESTAMP) { @@ -281,7 +401,12 @@ private static function generateInternal( ); } - $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + $sequenceValue = self::sequence( + $now, + $nodeId, + $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream', + $resolvedSequenceProvider, + ); } if ($sequenceValue < 1) { throw new RandflakeException('randflake: sequence provider must return a positive integer'); @@ -301,12 +426,16 @@ private static function generateInternal( ); } - $providerState[$nodeId] = ['timestamp' => $now, 'sequence' => $sequence]; + $providerState[$domainKey] = ['timestamp' => $now, 'sequence' => $sequence]; $plain = self::packPayload($now, $nodeId, $sequence); - $cipher = self::permute($plain, $secret, false); + if ($format === RandflakeFormat::UPSTREAM) { + $cipher = self::sparx($secret)->encrypt(strrev($plain)); - return DecimalBytes::fromBytes($cipher); + return SignedDecimal64::fromLittleEndianBytes($cipher); + } + + return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); } private static function nowSeconds(?GenerationContext $runtime): int @@ -318,9 +447,14 @@ private static function nowSeconds(?GenerationContext $runtime): int * @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 ( @@ -452,16 +586,61 @@ private static function unpackPayload(string $payload): array return [$timestampPart + self::EPOCH_OFFSET, $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; + } + /** * @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'); } } + 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); + } + /** * @throws RandflakeException */ From bb3c3773a4e3e9aaff7f8a8d34f869b6c631b9f8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 21:48:45 +0600 Subject: [PATCH 021/107] test(randflake): verify pinned upstream vectors --- tests/RandflakeUpstreamFormatTest.php | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/RandflakeUpstreamFormatTest.php 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), + ); +}); From 839d96651747c09f569f1471a444b650e12b858c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:37:11 +0600 Subject: [PATCH 022/107] fix(qa): resolve analyzer findings in coordinated ID paths --- src/Randflake.php | 137 ++++++++---- src/Sequence/FilesystemSequenceProvider.php | 117 ++++++---- .../PsrSimpleCacheSequenceProvider.php | 206 +++++++++++------- src/Sonyflake.php | 127 ++++++++--- src/Support/BaseEncoder.php | 169 ++++++++------ src/Support/FileLock.php | 198 +++++++++++------ src/Support/SignedDecimal64.php | 8 +- src/UUID.php | 2 +- 8 files changed, 639 insertions(+), 325 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 6af1184..8405c55 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -42,7 +42,7 @@ final class Randflake public const int TIMESTAMP_BITS = 30; - /** @var \WeakMap>|null */ + /** @var \WeakMap>|null */ private static ?\WeakMap $lastTimestampByProvider = null; /** @var array> */ @@ -359,11 +359,62 @@ private static function generateInternal( self::validateLeaseWindow($leaseStart, $leaseEnd, $format, $leaseEndExclusive); $secret = self::validateSecret($secret); - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); - self::$lastTimestampByProvider ??= new \WeakMap(); - $providerState = self::$lastTimestampByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); + $provider = self::resolveSequenceProvider($sequenceProvider); + $state = self::providerState($provider); $domainKey = $format->value . ':' . $nodeId; $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, + ); + $sequence = self::normalizeAllocation($allocation, $last, $now); + $state[$domainKey] = ['timestamp' => $now, 'sequence' => $sequence]; + + return self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format); + } + + /** + * @return \ArrayObject + */ + private static function providerState(SequenceProviderInterface $provider): \ArrayObject + { + self::$lastTimestampByProvider ??= new \WeakMap(); + + /** @var \ArrayObject|null $state */ + $state = self::$lastTimestampByProvider[$provider] ?? null; + if ($state !== null) { + return $state; + } + + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastTimestampByProvider[$provider] = $state; + + return $state; + } + + 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'); } @@ -371,28 +422,28 @@ private static function generateInternal( if ($now > self::MAX_TIMESTAMP) { throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); } + } - $last = $providerState[$domainKey] ?? null; - $lastTimestamp = $last['timestamp'] ?? null; - if ($lastTimestamp !== null && $now < $lastTimestamp) { - throw new RandflakeException('randflake: timestamp consistency violation, the current time is less than the last time'); - } + /** + * @return array{0:int,1:int} + */ + private static function allocateSequence( + SequenceProviderInterface $provider, + int $nodeId, + int $now, + int $leaseStart, + int $leaseEnd, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ?GenerationContext $runtime, + ): array { + $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream'; try { - $sequenceValue = self::sequence( - $now, - $nodeId, - $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream', - $resolvedSequenceProvider, - ); + return [$now, self::sequence($now, $nodeId, $type, $provider)]; } catch (SequenceTimestampException $exception) { $now = self::nowSeconds($runtime); - if (!self::leaseContains($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive)) { - throw new RandflakeException('randflake: invalid lease, lease expired or not started yet', 0, $exception); - } - if ($now > self::MAX_TIMESTAMP) { - throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime', 0, $exception); - } + 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', @@ -401,23 +452,21 @@ private static function generateInternal( ); } - $sequenceValue = self::sequence( - $now, - $nodeId, - $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream', - $resolvedSequenceProvider, - ); + return [$now, self::sequence($now, $nodeId, $type, $provider)]; } - if ($sequenceValue < 1) { + } + + /** + * @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 = $sequenceValue - 1; - if ( - $last !== null - && $last['timestamp'] === $now - && $sequence <= $last['sequence'] - ) { + $sequence = $allocation - 1; + 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) { @@ -426,13 +475,21 @@ private static function generateInternal( ); } - $providerState[$domainKey] = ['timestamp' => $now, 'sequence' => $sequence]; + return $sequence; + } - $plain = self::packPayload($now, $nodeId, $sequence); + 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) { - $cipher = self::sparx($secret)->encrypt(strrev($plain)); - - return SignedDecimal64::fromLittleEndianBytes($cipher); + return SignedDecimal64::fromLittleEndianBytes( + self::sparx($secret)->encrypt(strrev($plain)), + ); } return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); @@ -512,7 +569,7 @@ private static function permute(string $block, string $secret, bool $decrypt): s 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 diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index bcd2350..200098b 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -14,6 +14,8 @@ final class FilesystemSequenceProvider implements SequenceProviderInterface { private const int MAX_PATH_CACHE = 1024; + private const int MAX_RESERVATIONS = 1024; + private const int MAX_SEQUENCE_STATE_BYTES = 64; private readonly string $baseDirectory; @@ -52,21 +54,10 @@ public function next(string $type, int $machineId, int $timestamp): int { $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']; - if ($allocation === $reservation['end']) { - unset($this->reservations[$fileLocation]); - } else { - $this->reservations[$fileLocation]['next'] = $allocation + 1; - } - return $allocation; + $reserved = $this->takeReservedAllocation($fileLocation, $timestamp); + if ($reserved !== null) { + return $reserved; } $handle = FileLock::acquire( @@ -78,41 +69,81 @@ public function next(string $type, int $machineId, int $timestamp): int ); try { - [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); - 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; - $reservationOffset = $this->reservationSize - 1; - if ($allocation > PHP_INT_MAX - $reservationOffset) { - throw new FileLockException('Sequence value exhausted'); - } - - $reservedEnd = $allocation + $reservationOffset; - $state = $timestamp . ',' . $reservedEnd; - $this->writeState($handle, $state, $oldLength); - if ($this->reservationSize > 1) { - 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, - ]; - } - - return $allocation; + return $this->allocateLocked($handle, $fileLocation, $timestamp); } finally { flock($handle, LOCK_UN); fclose($handle); } } + 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 + */ + private function allocateLocked($handle, string $fileLocation, int $timestamp): int + { + [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); + 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; + $reservationOffset = $this->reservationSize - 1; + if ($allocation > PHP_INT_MAX - $reservationOffset) { + throw new FileLockException('Sequence value exhausted'); + } + + $reservedEnd = $allocation + $reservationOffset; + $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength); + $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd); + + return $allocation; + } + + private function storeReservation( + string $fileLocation, + int $timestamp, + int $allocation, + int $reservedEnd, + ): void { + if ($this->reservationSize === 1) { + return; + } + + 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 static function isCanonicalInteger(string $value): bool { return $value !== '' diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 3dddd51..ed9ad0e 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -13,30 +13,36 @@ use Psr\SimpleCache\CacheInterface; use Throwable; -final readonly class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface +final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface { - private ?Closure $synchronizer; + private readonly ?Closure $synchronizer; - /** @var \ArrayObject */ - private \ArrayObject $observedState; + /** @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 ?GenerationContext $runtime = 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; - $this->observedState = new \ArrayObject(); } /** @@ -47,46 +53,58 @@ public function next(string $type, int $machineId, int $timestamp): int $key = $this->key($type, $machineId); 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; + return $this->nextSynchronized($key, $timestamp); } $lock = $this->acquireLock($key); + try { + return $this->nextSafely($key, $timestamp); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function nextSynchronized(string $key, int $timestamp): int + { + try { + $sequence = ($this->synchronizer)( + $key, + fn(): int => $this->nextFromCacheState($key, $timestamp), + ); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw $this->storageFailure($key, $exception); + } + if (!is_int($sequence) || $sequence < 1) { + throw new FileLockException('Sequence synchronizer must return a positive integer'); + } + + return $sequence; + } + + private function nextSafely(string $key, int $timestamp): int + { 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, - ); - } finally { - flock($lock, LOCK_UN); - fclose($lock); + throw $this->storageFailure($key, $exception); } } + 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, + ); + } + /** * @return resource * @throws FileLockException @@ -120,52 +138,14 @@ private function key(string $type, int $machineId): string private function nextFromCacheState(string $key, int $timestamp): int { - $state = $this->cache->get($key); + $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); } - $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 ( - $observed !== null - && ( - $state['timestamp'] < $observed['timestamp'] - || ($state['timestamp'] === $observed['timestamp'] && $state['sequence'] < $observed['sequence']) - ) - ) { - throw new FileLockException('Cached sequence state regressed 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; - } - } - + self::assertNotRegressed($state, $observed, $key); + $sequence = self::nextSequence($state, $timestamp, $key); $nextState = ['timestamp' => $timestamp, 'sequence' => $sequence]; if (!$this->cache->set($key, $nextState, null)) { throw new FileLockException('Failed to persist sequence state for key: ' . $key); @@ -175,4 +155,72 @@ private function nextFromCacheState(string $key, int $timestamp): int 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]; + } + + /** + * @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; + } } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index f414442..2b77205 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -268,41 +268,89 @@ private static function generateInternal( ?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."); - } + self::assertMachineId($machineId); - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); + $provider = self::resolveSequenceProvider($sequenceProvider); self::$lastWallTimeByProvider ??= new \WeakMap(); - $providerState = self::$lastWallTimeByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); - $currentTime = self::nowMilliseconds($runtime); - $domainKey = $startTimestamp . ':' . $machineId; - $lastWallTime = $providerState[$domainKey] ?? 0; - if ($currentTime < $lastWallTime) { - if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { - throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); - } - $currentTime = self::waitUntilWallTime($lastWallTime, $runtime); + /** @var \ArrayObject|null $providerState */ + $providerState = self::$lastWallTimeByProvider[$provider] ?? null; + if ($providerState === null) { + /** @var \ArrayObject $providerState */ + $providerState = new \ArrayObject(); + self::$lastWallTimeByProvider[$provider] = $providerState; } + $domainKey = $format->value . ':' . $startTimestamp . ':' . $machineId; + $currentTime = self::resolveWallTime( + self::nowMilliseconds($runtime), + $providerState[$domainKey] ?? 0, + $clockBackwardPolicy, + $runtime, + ); $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); self::ensureEffectiveRuntime($elapsedTime); + + [$elapsedTime, $sequence] = self::allocateSequence( + $provider, + $machineId, + $startTimestamp, + $elapsedTime, + $clockBackwardPolicy, + $runtime, + $format, + ); + $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); + self::ensureEffectiveRuntime($elapsedTime); + + return self::packId($elapsedTime, $machineId, $sequence, $format); + } + + 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."); + } + } + + 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); + } + + /** + * @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 { - $sequence = self::sequence( - $elapsedTime, - $machineId, - $sequenceType, - $resolvedSequenceProvider, - ); + $allocation = self::sequence($elapsedTime, $machineId, $sequenceType, $provider); } catch (SequenceTimestampException $exception) { - if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + if ($policy === ClockBackwardPolicy::THROW) { throw new SonyflakeException( 'Clock moved backwards while generating Sonyflake ID', 0, @@ -315,29 +363,36 @@ private static function generateInternal( continue; } - if ($sequence < 1) { + if ($allocation < 1) { throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation'); } - - if ($sequence <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) { - --$sequence; - - break; + if ($allocation <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) { + return [$elapsedTime, $allocation - 1]; } $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp, $runtime); } - $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); - - self::ensureEffectiveRuntime($elapsedTime); + } - return (string) ($format === SonyflakeFormat::UPSTREAM - ? ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) + 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) - : ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) - | ($machineId << self::SEQUENCE_BITS) - | $sequence)); + | $machineId + ); + } + + return (string) ( + ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS)) + | ($machineId << self::SEQUENCE_BITS) + | $sequence + ); } /** diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index c4af6b7..acc116e 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -19,117 +19,160 @@ final class BaseEncoder private const int MAX_BYTE_LENGTH = 1024; - /** - * 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'); + self::assertByteLength($bytesLength); + if ($base === 16) { + return self::decodeHex($encoded, $bytesLength); } + return self::decodeRadix($encoded, $base, $bytesLength); + } + + public static function encodeBytes(string $bytes, int $base): string + { + self::assertByteLength(strlen($bytes)); if ($base === 16) { - if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { - throw new InvalidArgumentException('Invalid character for base 16'); - } + return ltrim(bin2hex($bytes), '0') ?: '0'; + } - $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); - $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); + $alphabet = self::alphabet($base); + if (trim($bytes, "\0") === '') { + return $alphabet[0]; + } - return $decoded; + return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); + } + + 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 decodeHex(string $encoded, int $bytesLength): string + { + if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { + throw new InvalidArgumentException('Invalid character for base 16'); } + $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); + $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); + + return $decoded; + } + + 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); - - for ($index = 0; $index < $encodedLength; ++$index) { - $char = $encoded[$index]; - $alphabetIndex = strpos($alphabet, $char); - $alphabetIndex !== false || throw new InvalidArgumentException('Invalid character for base ' . $base); - - $carry = $alphabetIndex; - $byteCount = count($bytes); - for ($byteIndex = $byteCount - 1; $byteIndex >= 0; --$byteIndex) { - $value = ($bytes[$byteIndex] * $base) + $carry; - $bytes[$byteIndex] = $value & 0xff; - $carry = $value >> 8; - } - - while ($carry > 0) { - array_unshift($bytes, $carry & 0xff); - $carry >>= 8; + $length = strlen($encoded); + for ($index = 0; $index < $length; ++$index) { + $digit = strpos($alphabet, $encoded[$index]); + if ($digit === false) { + throw new InvalidArgumentException('Invalid character for base ' . $base); } + $bytes = self::appendDigit($bytes, $base, $digit); if (count($bytes) > $bytesLength) { throw new InvalidArgumentException('Encoded value exceeds target byte length'); } } - $decoded = ''; - foreach ($bytes as $byte) { - $decoded .= chr($byte); - } + $decoded = self::byteString($bytes); return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } /** - * Encodes bytes into one of supported bases (16/32/36/58/62). + * @param list $bytes + * @return list */ - public static function encodeBytes(string $bytes, int $base): string + private static function appendDigit(array $bytes, int $base, int $digit): array { - $byteLength = strlen($bytes); - if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new InvalidArgumentException('Byte length must be between 1 and 1024'); + $carry = $digit; + for ($index = count($bytes) - 1; $index >= 0; --$index) { + $value = ($bytes[$index] * $base) + $carry; + $bytes[$index] = $value & 0xff; + $carry = $value >> 8; } - if ($base === 16) { - return ltrim(bin2hex($bytes), '0') ?: '0'; + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; } - $alphabet = self::alphabet($base); + return $bytes; + } + + /** + * @param list $bytes + */ + private static function byteString(array $bytes): string + { + $decoded = ''; + foreach ($bytes as $byte) { + $decoded .= chr($byte); + } + + return $decoded; + } + + /** + * @return list + */ + private static function unpackBytes(string $bytes): array + { $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]; - } + return array_values($unpacked); + } + /** + * @param list $number + */ + private static function encodeRadix(array $number, int $base, string $alphabet): string + { $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; - } - } - + [$number, $remainder] = self::divide($number, $base); $encoded = $alphabet[$remainder] . $encoded; - $number = $quotient; } return $encoded; } + /** + * @param list $number + * @return array{0:list,1:int} + */ + private static function divide(array $number, int $base): array + { + $quotient = []; + $remainder = 0; + foreach ($number as $byte) { + $value = ($remainder << 8) | $byte; + $digit = intdiv($value, $base); + $remainder = $value % $base; + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } + } + + return [$quotient, $remainder]; + } + private static function alphabet(int $base): string { return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 550c1d9..751fb3c 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -4,11 +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 @@ -21,42 +24,29 @@ public static function acquire( ?GenerationContext $runtime = null, ) { $handle = self::openVerified($path, $openErrorMessage); - - if ($timeoutMicros === null && $runtime === null) { - if (flock($handle, LOCK_EX)) { - return $handle; - } - - fclose($handle); - - throw new FileLockException($lockErrorMessage); - } - - $effectiveTimeout = $timeoutMicros ?? $runtime?->waitTimeoutMicros ?? 1_000_000; - $deadline = hrtime(true) + ($effectiveTimeout * 1000); + $timeout = $timeoutMicros ?? $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + $deadline = hrtime(true) + ($timeout * 1_000); $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); if ($runtimeDeadline !== null) { $deadline = min($deadline, $runtimeDeadline); } - do { - $wouldBlock = 0; - if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { - return $handle; - } - - if ($wouldBlock !== 1) { - fclose($handle); - - throw new FileLockException($lockErrorMessage); - } + try { + do { + $wouldBlock = 0; + if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { + return $handle; + } + if ($wouldBlock !== 1) { + throw new FileLockException($lockErrorMessage); + } - if ($runtime !== null) { - $runtime->sleepMicroseconds(1_000); - } else { - usleep(1_000); - } - } while (hrtime(true) < $deadline); + $runtime?->sleepMicroseconds(1_000) ?? usleep(1_000); + } while (hrtime(true) < $deadline); + } catch (\Throwable $exception) { + fclose($handle); + throw $exception; + } fclose($handle); @@ -69,46 +59,66 @@ public static function acquire( */ private static function openVerified(string $path, string $errorMessage) { - $before = @lstat($path); - $created = false; - if ($before === false) { - $handle = @fopen($path, 'x+b'); - if (is_resource($handle)) { - $created = true; - @chmod($path, 0600); - } else { - $before = @lstat($path); - $handle = $before === false ? false : @fopen($path, 'r+b'); + $before = self::pathMetadata($path); + if ($before !== false) { + self::assertSafeMetadata($before, $errorMessage); + + return self::openExisting($path, $before, $errorMessage); + } + + $handle = self::openStream($path, 'x+b'); + if (!is_resource($handle)) { + $before = self::pathMetadata($path); + if ($before === false) { + throw new FileLockException($errorMessage); } - } else { + self::assertSafeMetadata($before, $errorMessage); - $handle = @fopen($path, 'r+b'); + + return self::openExisting($path, $before, $errorMessage); } + if (!self::changePermissions($path, 0600)) { + fclose($handle); + throw new FileLockException($errorMessage); + } + + return self::verifyHandle($path, $handle, null, $errorMessage); + } + + /** + * @param array $before + * @return resource + */ + private static function openExisting(string $path, array $before, string $errorMessage) + { + $handle = self::openStream($path, 'r+b'); if (!is_resource($handle)) { throw new FileLockException($errorMessage); } + return self::verifyHandle($path, $handle, $before, $errorMessage); + } + + /** + * @param resource $handle + * @param array|null $before + * @return resource + */ + private static function verifyHandle(string $path, $handle, ?array $before, string $errorMessage) + { try { $after = fstat($handle); - $pathState = @lstat($path); + $pathState = self::pathMetadata($path); if ($after === false || $pathState === false) { throw new FileLockException($errorMessage); } self::assertSafeMetadata($after, $errorMessage); self::assertSafeMetadata($pathState, $errorMessage); - if ( - isset($after['dev'], $after['ino'], $pathState['dev'], $pathState['ino']) - && ($after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino']) - ) { - throw new FileLockException($errorMessage); - } - - if (!$created && $before !== false && isset($before['dev'], $before['ino'], $after['dev'], $after['ino'])) { - if ($before['dev'] !== $after['dev'] || $before['ino'] !== $after['ino']) { - throw new FileLockException($errorMessage); - } + self::assertSameFile($after, $pathState, $errorMessage); + if ($before !== null) { + self::assertSameFile($before, $after, $errorMessage); } return $handle; @@ -119,21 +129,85 @@ private static function openVerified(string $path, string $errorMessage) } /** - * @param array $metadata + * @param array $left + * @param array $right + */ + private static function assertSameFile(array $left, array $right, string $errorMessage): void + { + if ($left['dev'] !== $right['dev'] || $left['ino'] !== $right['ino']) { + throw new FileLockException($errorMessage); + } + } + + /** + * @return array|false + */ + private static function pathMetadata(string $path): array|false + { + try { + $metadata = self::invokeFilesystem(static fn(): array|false => lstat($path)); + + return $metadata; + } catch (ErrorException) { + return false; + } + } + + /** + * @return resource|false + */ + private static function openStream(string $path, string $mode) + { + try { + return self::invokeFilesystem(static fn() => fopen($path, $mode)); + } catch (ErrorException) { + return false; + } + } + + private static function changePermissions(string $path, int $permissions): bool + { + try { + return self::invokeFilesystem(static fn(): bool => chmod($path, $permissions)); + } catch (ErrorException) { + return false; + } + } + + /** + * @template T + * @param callable():T $operation + * @return T + * @throws ErrorException + */ + private static function invokeFilesystem(callable $operation): mixed + { + set_error_handler( + static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, + ); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + + /** + * @param array $metadata * @throws FileLockException */ private static function assertSafeMetadata(array $metadata, string $errorMessage): void { - $mode = $metadata['mode'] ?? null; - if (!is_int($mode) || ($mode & 0170000) !== 0100000) { + $mode = $metadata['mode']; + if (($mode & 0170000) !== 0100000) { throw new FileLockException($errorMessage); } - if (function_exists('posix_geteuid')) { - $uid = $metadata['uid'] ?? null; - if (!is_int($uid) || $uid !== posix_geteuid()) { - throw new FileLockException($errorMessage); - } + if (function_exists('posix_geteuid') && $metadata['uid'] !== posix_geteuid()) { + throw new FileLockException($errorMessage); } } } diff --git a/src/Support/SignedDecimal64.php b/src/Support/SignedDecimal64.php index fec9fae..263502b 100644 --- a/src/Support/SignedDecimal64.php +++ b/src/Support/SignedDecimal64.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Support; use InvalidArgumentException; +use LogicException; final class SignedDecimal64 { @@ -88,7 +89,12 @@ private static function subtract(string $left, string $right): string $borrow = 0; } - $result = chr(48 + $digit - $rightDigit) . $result; + $difference = $digit - $rightDigit; + if ($difference < 0 || $difference > 9) { + throw new LogicException('Signed decimal subtraction produced an invalid digit'); + } + + $result = (string) $difference . $result; --$leftIndex; --$rightIndex; } diff --git a/src/UUID.php b/src/UUID.php index b0c53b2..784f7da 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -554,7 +554,7 @@ private static function incrementV7Tail(string $tail): ?string { $tail = strtolower($tail); for ($index = strlen($tail) - 1; $index >= 1; --$index) { - $value = hexdec($tail[$index]); + $value = intval($tail[$index], 16); $maximum = $index === 4 ? 3 : 15; $value = $index === 4 ? $value & 3 : $value; if ($value < $maximum) { From 04f8e7e82623bf8c8354acd312258519815f59e8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:42:11 +0600 Subject: [PATCH 023/107] fix(analysis): resolve runtime type diagnostics --- src/Sequence/PsrSimpleCacheSequenceProvider.php | 8 ++++---- src/Support/BaseEncoder.php | 10 ++++++++-- src/Support/FileLock.php | 17 ++++++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index ed9ad0e..46dc3ca 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -53,7 +53,7 @@ public function next(string $type, int $machineId, int $timestamp): int $key = $this->key($type, $machineId); if ($this->synchronizer !== null) { - return $this->nextSynchronized($key, $timestamp); + return $this->nextSynchronized($this->synchronizer, $key, $timestamp); } $lock = $this->acquireLock($key); @@ -65,10 +65,10 @@ public function next(string $type, int $machineId, int $timestamp): int } } - private function nextSynchronized(string $key, int $timestamp): int + private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp): int { try { - $sequence = ($this->synchronizer)( + $sequence = $synchronizer( $key, fn(): int => $this->nextFromCacheState($key, $timestamp), ); @@ -147,7 +147,7 @@ private function nextFromCacheState(string $key, int $timestamp): int self::assertNotRegressed($state, $observed, $key); $sequence = self::nextSequence($state, $timestamp, $key); $nextState = ['timestamp' => $timestamp, 'sequence' => $sequence]; - if (!$this->cache->set($key, $nextState, null)) { + if (!$this->cache->set($key, $nextState)) { throw new FileLockException('Failed to persist sequence state for key: ' . $key); } diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index acc116e..a5ebbfb 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -112,7 +112,7 @@ private static function appendDigit(array $bytes, int $base, int $digit): array $carry >>= 8; } - return $bytes; + return array_values($bytes); } /** @@ -136,7 +136,13 @@ private static function unpackBytes(string $bytes): array $unpacked = unpack('C*', $bytes); $unpacked !== false || throw new \LogicException('Unable to unpack byte value'); - return array_values($unpacked); + $number = []; + foreach ($unpacked as $byte) { + is_int($byte) || throw new \LogicException('Unable to unpack byte value'); + $number[] = $byte; + } + + return $number; } /** diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 751fb3c..235b391 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -24,7 +24,12 @@ public static function acquire( ?GenerationContext $runtime = null, ) { $handle = self::openVerified($path, $openErrorMessage); - $timeout = $timeoutMicros ?? $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + $timeout = $timeoutMicros; + if ($timeout === null) { + $timeout = $runtime === null + ? self::DEFAULT_TIMEOUT_MICROS + : $runtime->waitTimeoutMicros; + } $deadline = hrtime(true) + ($timeout * 1_000); $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); if ($runtimeDeadline !== null) { @@ -41,7 +46,11 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } - $runtime?->sleepMicroseconds(1_000) ?? usleep(1_000); + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } while (hrtime(true) < $deadline); } catch (\Throwable $exception) { fclose($handle); @@ -145,9 +154,7 @@ private static function assertSameFile(array $left, array $right, string $errorM private static function pathMetadata(string $path): array|false { try { - $metadata = self::invokeFilesystem(static fn(): array|false => lstat($path)); - - return $metadata; + return self::invokeFilesystem(static fn(): array|false => lstat($path)); } catch (ErrorException) { return false; } From cfb0496ff475306f9b7268319be6c89a6fcd17ac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:42:40 +0600 Subject: [PATCH 024/107] fix(qa): clean diagnostics and test teardown --- src/Randflake.php | 10 +++++----- src/Support/SignedDecimal64.php | 2 +- tests/SequenceSafetyTest.php | 20 +++++++++++++++----- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 8405c55..24deb54 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -84,11 +84,11 @@ public static function decodeString( $id, 32, 8, - static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException( - 'randflake: invalid id', - 0, - $exception, - ), + static function (string $message, \InvalidArgumentException $exception): RandflakeException { + unset($message); + + return new RandflakeException('randflake: invalid id', 0, $exception); + }, ); } diff --git a/src/Support/SignedDecimal64.php b/src/Support/SignedDecimal64.php index 263502b..b91f5eb 100644 --- a/src/Support/SignedDecimal64.php +++ b/src/Support/SignedDecimal64.php @@ -94,7 +94,7 @@ private static function subtract(string $left, string $right): string throw new LogicException('Signed decimal subtraction produced an invalid digit'); } - $result = (string) $difference . $result; + $result = $difference . $result; --$leftIndex; --$rightIndex; } diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php index 1cdbd12..6c583c4 100644 --- a/tests/SequenceSafetyTest.php +++ b/tests/SequenceSafetyTest.php @@ -24,9 +24,15 @@ expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class) ->and(file_get_contents($target))->toBe('unchanged'); } finally { - @unlink($link); - @unlink($target); - @rmdir($directory); + if (is_link($link)) { + unlink($link); + } + if (is_file($target)) { + unlink($target); + } + if (is_dir($directory)) { + rmdir($directory); + } } }); @@ -41,7 +47,11 @@ expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class) ->and(file_get_contents($state))->toBe('100,' . PHP_INT_MAX); } finally { - @unlink($state); - @rmdir($directory); + if (is_file($state)) { + unlink($state); + } + if (is_dir($directory)) { + rmdir($directory); + } } }); From a6e5f289173ab2d9db81b9f509ad17fbfc93b4b6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:43:09 +0600 Subject: [PATCH 025/107] style(uid): normalize optional runtime calls --- src/Snowflake.php | 1 - src/Sonyflake.php | 1 - src/TBSL.php | 1 - 3 files changed, 3 deletions(-) diff --git a/src/Snowflake.php b/src/Snowflake.php index f9923e6..0320a85 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -73,7 +73,6 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string $workerId, self::getStartTimeStamp(), ClockBackwardPolicy::WAIT, - runtime: null, ); } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 2b77205..23ebd83 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -82,7 +82,6 @@ public static function generate(int $machineId = 0): string $machineId, self::getStartTimeStamp(SonyflakeFormat::UID), ClockBackwardPolicy::WAIT, - runtime: null, format: SonyflakeFormat::UID, ); } diff --git a/src/TBSL.php b/src/TBSL.php index d709d06..0febe1e 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -61,7 +61,6 @@ public static function generate(int $machineId = 0, bool $sequenced = true): str $machineId, $sequenced, ClockBackwardPolicy::WAIT, - runtime: null, ); } From a7d0f835adcfbfdd63947be60b72756e006e48dd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:46:37 +0600 Subject: [PATCH 026/107] style(uid): order runtime and allocation members --- src/Randflake.php | 309 ++++++++++---------- src/Runtime/GenerationContext.php | 29 +- src/Sequence/FilesystemSequenceProvider.php | 107 +++---- 3 files changed, 224 insertions(+), 221 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 24deb54..161a743 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -51,7 +51,7 @@ final class Randflake /** @var array */ private static array $sparxCache = []; - /** +/** * @throws RandflakeException */ public static function decodeString( @@ -92,7 +92,7 @@ static function (string $message, \InvalidArgumentException $exception): Randfla ); } - /** +/** * @throws RandflakeException */ public static function encodeString( @@ -111,7 +111,7 @@ public static function encodeString( ); } - /** +/** * @throws RandflakeException */ public static function fromBase( @@ -141,7 +141,7 @@ public static function fromBase( ); } - /** +/** * @throws RandflakeException */ public static function fromBytes( @@ -164,7 +164,7 @@ public static function fromBytes( ); } - /** +/** * @throws RandflakeException|FileLockException */ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret): string @@ -181,7 +181,7 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\ ); } - /** +/** * @throws RandflakeException|FileLockException */ public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret): string @@ -189,7 +189,7 @@ public static function generateString(int $nodeId, int $leaseStart, int $leaseEn return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); } - /** +/** * @throws RandflakeException|FileLockException */ public static function generateWithConfig(RandflakeConfig $config): string @@ -208,7 +208,7 @@ public static function generateWithConfig(RandflakeConfig $config): string ); } - /** +/** * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ @@ -234,7 +234,7 @@ public static function inspect( ]; } - /** +/** * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ @@ -246,7 +246,7 @@ public static function inspectString( return self::inspect(self::decodeString($id, $format), $secret, $format); } - public static function isValid( +public static function isValid( string $id, RandflakeFormat $format = RandflakeFormat::UID, ): bool { @@ -259,7 +259,7 @@ public static function isValid( && UnsignedDecimal::compare($id, '18446744073709551615') <= 0; } - /** +/** * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ @@ -285,7 +285,7 @@ public static function parse( ]; } - /** +/** * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ @@ -297,7 +297,7 @@ public static function parseString( return self::parse(self::decodeString($id, $format), $secret, $format); } - /** +/** * @throws RandflakeException */ public static function toBase( @@ -317,7 +317,7 @@ public static function toBase( ); } - /** +/** * @throws RandflakeException */ public static function toBytes( @@ -342,7 +342,72 @@ public static function toBytes( ); } - /** +/** + * @return array{0:int,1:int} + */ + private static function allocateSequence( + SequenceProviderInterface $provider, + int $nodeId, + int $now, + int $leaseStart, + int $leaseEnd, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ?GenerationContext $runtime, + ): array { + $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream'; + + try { + return [$now, self::sequence($now, $nodeId, $type, $provider)]; + } catch (SequenceTimestampException $exception) { + $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', + 0, + $exception, + ); + } + + return [$now, self::sequence($now, $nodeId, $type, $provider)]; + } + } + +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'); + } + + if ($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 SignedDecimal64::fromLittleEndianBytes( + self::sparx($secret)->encrypt(strrev($plain)), + ); + } + + return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); + } + +/** * @throws RandflakeException|FileLockException */ private static function generateInternal( @@ -388,75 +453,49 @@ private static function generateInternal( return self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format); } - /** - * @return \ArrayObject +/** + * @return array{0:int,1:int,2:int} + * @throws RandflakeException */ - private static function providerState(SequenceProviderInterface $provider): \ArrayObject - { - self::$lastTimestampByProvider ??= new \WeakMap(); + 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); - /** @var \ArrayObject|null $state */ - $state = self::$lastTimestampByProvider[$provider] ?? null; - if ($state !== null) { - return $state; + if ( + $timestamp < self::EPOCH_OFFSET + || $timestamp > self::MAX_TIMESTAMP + || $nodeId < 0 + || $nodeId > self::MAX_NODE + || $sequence < 0 + || $sequence > self::MAX_SEQUENCE + ) { + throw new RandflakeException('randflake: invalid id'); } - /** @var \ArrayObject $state */ - $state = new \ArrayObject(); - self::$lastTimestampByProvider[$provider] = $state; - - return $state; + return [$timestamp, $nodeId, $sequence]; } - private static function assertGenerationTime( - int $now, +private static function leaseContains( + int $timestamp, 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'); - } - - if ($now > self::MAX_TIMESTAMP) { - throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); + ): bool { + if ($format === RandflakeFormat::UPSTREAM) { + return $timestamp >= $leaseStart && $timestamp < (int) $leaseEndExclusive; } - } - /** - * @return array{0:int,1:int} - */ - private static function allocateSequence( - SequenceProviderInterface $provider, - int $nodeId, - int $now, - int $leaseStart, - int $leaseEnd, - RandflakeFormat $format, - ?int $leaseEndExclusive, - ?GenerationContext $runtime, - ): array { - $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream'; - - try { - return [$now, self::sequence($now, $nodeId, $type, $provider)]; - } catch (SequenceTimestampException $exception) { - $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', - 0, - $exception, - ); - } - - return [$now, self::sequence($now, $nodeId, $type, $provider)]; - } + return $timestamp >= $leaseStart && $timestamp <= $leaseEnd; } - /** +/** * @param array{timestamp:int,sequence:int}|null $last */ private static function normalizeAllocation(int $allocation, ?array $last, int $now): int @@ -478,57 +517,12 @@ private static function normalizeAllocation(int $allocation, ?array $last, int $ return $sequence; } - 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 SignedDecimal64::fromLittleEndianBytes( - self::sparx($secret)->encrypt(strrev($plain)), - ); - } - - return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); - } - - private static function nowSeconds(?GenerationContext $runtime): int +private static function nowSeconds(?GenerationContext $runtime): int { return $runtime?->nowSeconds() ?? time(); } - /** - * @return array{0:int,1:int,2:int} - * @throws RandflakeException - */ - 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 ( - $timestamp < self::EPOCH_OFFSET - || $timestamp > self::MAX_TIMESTAMP - || $nodeId < 0 - || $nodeId > self::MAX_NODE - || $sequence < 0 - || $sequence > self::MAX_SEQUENCE - ) { - throw new RandflakeException('randflake: invalid id'); - } - - return [$timestamp, $nodeId, $sequence]; - } - - private static function packPayload(int $timestamp, int $nodeId, int $sequence): string +private static function packPayload(int $timestamp, int $nodeId, int $sequence): string { $timestampPart = $timestamp - self::EPOCH_OFFSET; $high = (($timestampPart & self::MAX_TIMESTAMP_PART) << 2) | (($nodeId >> 15) & 0x03); @@ -537,7 +531,7 @@ private static function packPayload(int $timestamp, int $nodeId, int $sequence): return pack('N2', $high, $low); } - /** +/** * Small secret-key permutation over 64-bit blocks to protect payload fields. */ private static function permute(string $block, string $secret, bool $decrypt): string @@ -567,12 +561,32 @@ private static function permute(string $block, string $secret, bool $decrypt): s return pack('N2', $left, $right); } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface +/** + * @return \ArrayObject + */ + private static function providerState(SequenceProviderInterface $provider): \ArrayObject + { + self::$lastTimestampByProvider ??= new \WeakMap(); + + /** @var \ArrayObject|null $state */ + $state = self::$lastTimestampByProvider[$provider] ?? null; + if ($state !== null) { + return $state; + } + + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastTimestampByProvider[$provider] = $state; + + return $state; + } + +private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64); } - private static function roundFunction(int $value, int $key): int +private static function roundFunction(int $value, int $key): int { $mask = 0xffffffff; $value &= $mask; @@ -585,7 +599,7 @@ private static function roundFunction(int $value, int $key): int return $mixed & $mask; } - /** +/** * @return array */ private static function roundKeys(string $secret): array @@ -609,7 +623,21 @@ 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 */ @@ -627,7 +655,7 @@ private static function unpackedInt(array|false $parts, string $key): int return $value; } - /** +/** * @return array{0:int,1:int,2:int} */ private static function unpackPayload(string $payload): array @@ -643,21 +671,7 @@ private static function unpackPayload(string $payload): array return [$timestampPart + self::EPOCH_OFFSET, $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; - } - - /** +/** * @throws RandflakeException */ private static function validateLeaseWindow( @@ -684,21 +698,7 @@ private static function validateLeaseWindow( } } - 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); - } - - /** +/** * @throws RandflakeException */ private static function validateNode(int $nodeId): void @@ -708,7 +708,7 @@ private static function validateNode(int $nodeId): void } } - /** +/** * @throws RandflakeException */ private static function validateSecret(#[\SensitiveParameter] string $secret): string @@ -719,4 +719,5 @@ private static function validateSecret(#[\SensitiveParameter] string $secret): s return $secret; } + } diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php index 91f8b74..4b51e39 100644 --- a/src/Runtime/GenerationContext.php +++ b/src/Runtime/GenerationContext.php @@ -11,7 +11,7 @@ { private const int DEFAULT_WAIT_TIMEOUT_MICROS = 1_000_000; - public function __construct( +public function __construct( public ?ClockInterface $clock = null, public ?RunwireBinding $runwire = null, public int $waitTimeoutMicros = self::DEFAULT_WAIT_TIMEOUT_MICROS, @@ -21,12 +21,12 @@ public function __construct( } } - public function assertActive(): void +public function assertActive(): void { $this->runwire?->assertActive(); } - public function nowMicroseconds(): int +public function nowMicroseconds(): int { $this->assertActive(); @@ -37,25 +37,17 @@ public function nowMicroseconds(): int return (int) $this->clock->now()->format('Uu'); } - public function nowMilliseconds(): int +public function nowMilliseconds(): int { return intdiv($this->nowMicroseconds(), 1_000); } - public function nowSeconds(): int +public function nowSeconds(): int { return intdiv($this->nowMicroseconds(), 1_000_000); } - public function waitDeadlineNanoseconds(): int - { - $deadline = hrtime(true) + ($this->waitTimeoutMicros * 1_000); - $runwireDeadline = $this->runwire?->deadlineNanoseconds(); - - return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline); - } - - public function sleepMicroseconds(int $microseconds): void +public function sleepMicroseconds(int $microseconds): void { $this->assertActive(); if ($this->runwire !== null) { @@ -67,4 +59,13 @@ public function sleepMicroseconds(int $microseconds): void usleep($microseconds); } + +public function waitDeadlineNanoseconds(): int + { + $deadline = hrtime(true) + ($this->waitTimeoutMicros * 1_000); + $runwireDeadline = $this->runwire?->deadlineNanoseconds(); + + return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline); + } + } diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 200098b..34e27aa 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -28,7 +28,7 @@ final class FilesystemSequenceProvider implements SequenceProviderInterface private ?int $sourcePid = null; - public function __construct( +public function __construct( ?string $baseDirectory = null, private readonly string $namespace = '', private readonly ?int $lockTimeoutMicros = null, @@ -50,7 +50,7 @@ public function __construct( } } - public function next(string $type, int $machineId, int $timestamp): int +public function next(string $type, int $machineId, int $timestamp): int { $fileLocation = $this->sequenceFileLocation($type, $machineId); $this->resetAfterFork(); @@ -76,28 +76,14 @@ public function next(string $type, int $machineId, int $timestamp): int } } - private function takeReservedAllocation(string $fileLocation, int $timestamp): ?int +private static function isCanonicalInteger(string $value): bool { - $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; + return $value !== '' + && ctype_digit($value) + && ($value === '0' || $value[0] !== '0'); } - /** +/** * @param resource $handle */ private function allocateLocked($handle, string $fileLocation, int $timestamp): int @@ -123,35 +109,7 @@ private function allocateLocked($handle, string $fileLocation, int $timestamp): return $allocation; } - private function storeReservation( - string $fileLocation, - int $timestamp, - int $allocation, - int $reservedEnd, - ): void { - if ($this->reservationSize === 1) { - return; - } - - 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 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} */ @@ -194,7 +152,7 @@ private function readState($handle): array return [(int) $timestamp, (int) $allocation, $oldLength]; } - private function resetAfterFork(): void +private function resetAfterFork(): void { $pid = (int) getmypid(); if ($pid === $this->sourcePid) { @@ -205,7 +163,7 @@ private function resetAfterFork(): void $this->reservations = []; } - private function sequenceFileLocation(string $type, int $machineId): string +private function sequenceFileLocation(string $type, int $machineId): string { $cacheKey = $type . ':' . $machineId; if (isset($this->pathCache[$cacheKey])) { @@ -224,7 +182,49 @@ 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 ($this->reservationSize === 1) { + return; + } + + 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 */ private function writeState($handle, string $state, int $oldLength): void @@ -241,4 +241,5 @@ private function writeState($handle, string $state, int $oldLength): void fflush($handle) || throw new FileLockException('Unable to flush sequence state'); } + } From 7c57ea1450afb7dea83a87c8bdd52bb169b75cfe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:47:14 +0600 Subject: [PATCH 027/107] style(uid): order coordinated generator members --- .../PsrSimpleCacheSequenceProvider.php | 149 +++++++------- src/Snowflake.php | 55 +++-- src/Sonyflake.php | 191 +++++++++--------- 3 files changed, 197 insertions(+), 198 deletions(-) diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 46dc3ca..2ca0033 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -20,7 +20,7 @@ final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface /** @var array */ private array $observedState = []; - /** +/** * @param callable(string, callable():int):mixed|null $synchronizer */ public function __construct( @@ -45,7 +45,7 @@ public function __construct( $this->synchronizer = $synchronizer ? $synchronizer(...) : null; } - /** +/** * @throws FileLockException */ public function next(string $type, int $machineId, int $timestamp): int @@ -65,47 +65,49 @@ public function next(string $type, int $machineId, int $timestamp): int } } - private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp): int +/** + * @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 { - try { - $sequence = $synchronizer( - $key, - fn(): int => $this->nextFromCacheState($key, $timestamp), - ); - } catch (FileLockException $exception) { - throw $exception; - } catch (Throwable $exception) { - throw $this->storageFailure($key, $exception); + if ($state === null || $observed === null) { + return; } - - if (!is_int($sequence) || $sequence < 1) { - throw new FileLockException('Sequence synchronizer must return a positive integer'); + 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); } - - return $sequence; } - private function nextSafely(string $key, int $timestamp): int +/** + * @param array{timestamp:int,sequence:int}|null $state + */ + private static function nextSequence(?array $state, int $timestamp, string $key): int { - try { - return $this->nextFromCacheState($key, $timestamp); - } catch (FileLockException $exception) { - throw $exception; - } catch (Throwable $exception) { - throw $this->storageFailure($key, $exception); + 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); } - } - 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, - ); + return $state['sequence'] + 1; } - /** +/** * @return resource * @throws FileLockException */ @@ -122,7 +124,7 @@ private function acquireLock(string $key) ); } - private function key(string $type, int $machineId): string +private function key(string $type, int $machineId): string { if (preg_match('/^[A-Za-z0-9_.]+$/D', $type) !== 1) { throw new InvalidArgumentException('Sequence type contains characters not guaranteed by PSR-16'); @@ -136,7 +138,7 @@ 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): int { $state = $this->normalizeState($this->cache->get($key), $key); $observed = $this->observedState[$key] ?? null; @@ -156,7 +158,38 @@ private function nextFromCacheState(string $key, int $timestamp): int return $sequence; } - /** +private function nextSafely(string $key, int $timestamp): int + { + try { + return $this->nextFromCacheState($key, $timestamp); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw $this->storageFailure($key, $exception); + } + } + +private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp): int + { + try { + $sequence = $synchronizer( + $key, + fn(): int => $this->nextFromCacheState($key, $timestamp), + ); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + 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 @@ -182,45 +215,13 @@ private function normalizeState(mixed $state, string $key): ?array return ['timestamp' => $stateTimestamp, 'sequence' => $stateSequence]; } - /** - * @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 +private function storageFailure(string $key, Throwable $exception): FileLockException { - 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); - } + return new FileLockException( + 'Failed to read/write sequence state from PSR cache for key: ' . $key, + 0, + $exception, + ); } - /** - * @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; - } } diff --git a/src/Snowflake.php b/src/Snowflake.php index 0320a85..b069109 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -38,7 +38,7 @@ final class Snowflake /** @var \WeakMap>|null */ private static ?\WeakMap $lastStateByProvider = null; - /** +/** * Decodes one of bases: 16, 32, 36, 58, 62 into Snowflake decimal. * * @throws SnowflakeException @@ -48,7 +48,7 @@ public static function fromBase(string $encoded, int $base): string return self::decodeNumericBase($encoded, $base); } - /** +/** * Converts 8-byte Snowflake binary data to decimal string. * * @throws SnowflakeException @@ -58,7 +58,7 @@ public static function fromBytes(string $bytes): string return self::decodeNumericBytes($bytes); } - /** +/** * Generates a unique snowflake ID. * * @param int $datacenter The ID of the datacenter (default: 0) @@ -76,7 +76,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string ); } - /** +/** * Generates Snowflake using configuration object. * * @throws SnowflakeException|FileLockException @@ -96,7 +96,7 @@ public static function generateWithConfig(SnowflakeConfig $config): string ); } - /** +/** * Checks whether a Snowflake ID string has a valid numeric shape. */ public static function isValid(string $id): bool @@ -106,7 +106,7 @@ public static function isValid(string $id): bool && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } - /** +/** * Parse the given ID into components. * * @param string $id The ID to parse. @@ -121,7 +121,7 @@ public static function parse(string $id): array ); } - /** +/** * Parse Snowflake ID using a custom epoch in milliseconds. * * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} @@ -150,7 +150,7 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array ]; } - /** +/** * Encodes Snowflake bytes into one of bases: 16, 32, 36, 58, 62. * * @throws SnowflakeException @@ -160,7 +160,7 @@ public static function toBase(string $id, int $base): string return BaseEncoder::encodeBytes(self::toBytes($id), $base); } - /** +/** * Converts a Snowflake decimal string to 8-byte binary representation. * * @throws SnowflakeException @@ -170,8 +170,7 @@ public static function toBytes(string $id): string return self::encodeNumericBytes($id); } - - private static function assertDecodedId(string $id): string +private static function assertDecodedId(string $id): string { if (!self::isValid($id)) { throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); @@ -180,7 +179,7 @@ private static function assertDecodedId(string $id): string return $id; } - /** +/** * @throws SnowflakeException */ private static function assertNodeIds(int $datacenter, int $workerId): void @@ -197,7 +196,7 @@ private static function assertNodeIds(int $datacenter, int $workerId): void } } - /** +/** * @throws SnowflakeException */ private static function assertTimestampRange(int $currentTime, int $startTimestamp): void @@ -213,7 +212,7 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta } } - private static function decodeNumericBase(string $encoded, int $base): string +private static function decodeNumericBase(string $encoded, int $base): string { $id = NumericConversion::decimalFromBase( $encoded, @@ -225,7 +224,7 @@ private static function decodeNumericBase(string $encoded, int $base): string return self::assertDecodedId($id); } - private static function decodeNumericBytes(string $bytes): string +private static function decodeNumericBytes(string $bytes): string { $id = NumericConversion::decimalFromBytes( $bytes, @@ -237,8 +236,7 @@ private static function decodeNumericBytes(string $bytes): string return self::assertDecodedId($id); } - - private static function encodeNumericBytes(string $id): string +private static function encodeNumericBytes(string $id): string { return NumericConversion::bytesFromDecimal( $id, @@ -250,7 +248,7 @@ private static function encodeNumericBytes(string $id): string ); } - /** +/** * @throws SnowflakeException|FileLockException */ private static function generateInternal( @@ -324,7 +322,7 @@ private static function generateInternal( | ($sequence)); } - /** +/** * Retrieves the start timestamp. */ private static function getStartTimeStamp(): int @@ -332,7 +330,7 @@ private static function getStartTimeStamp(): int return self::DEFAULT_EPOCH; } - /** +/** * @return array{0:int, 1:int} * @throws FileLockException|SnowflakeException */ @@ -377,12 +375,17 @@ private static function nextSequenceAtValidTimestamp( } } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface +private static function nowMilliseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); + } + +private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } - /** +/** * @return array{0:string,1:string} */ private static function timestampParts(int $timestamp): array @@ -390,12 +393,7 @@ private static function timestampParts(int $timestamp): array return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } - private static function nowMilliseconds(?GenerationContext $runtime): int - { - return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); - } - - private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int +private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); @@ -414,4 +412,5 @@ private static function waitUntil(int $timestamp, ?GenerationContext $runtime): return $now; } + } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 23ebd83..193b2cd 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -39,7 +39,7 @@ final class Sonyflake /** @var \WeakMap>|null */ private static ?\WeakMap $lastWallTimeByProvider = null; - /** +/** * Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal. * * @throws SonyflakeException @@ -54,7 +54,7 @@ public static function fromBase(string $encoded, int $base): string return self::assertDecodedId($id); } - /** +/** * Converts 8-byte Sonyflake binary data to decimal string. * * @throws SonyflakeException @@ -69,7 +69,7 @@ public static function fromBytes(string $bytes): string return self::assertDecodedId($id); } - /** +/** * Generates a unique identifier using the SonyFlake algorithm. * * @param int $machineId The machine identifier. Must be between 0 and the maximum machine ID. @@ -86,7 +86,7 @@ public static function generate(int $machineId = 0): string ); } - /** +/** * Generates Sonyflake using configuration object. * * @throws SonyflakeException|FileLockException @@ -103,7 +103,7 @@ public static function generateWithConfig(SonyflakeConfig $config): string ); } - /** +/** * Checks whether a Sonyflake ID string has a valid numeric shape. */ public static function isValid(string $id): bool @@ -113,7 +113,7 @@ public static function isValid(string $id): bool && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } - /** +/** * Parse the given ID into components. * * @param string $id The ID to parse. @@ -125,7 +125,7 @@ public static function parse(string $id, SonyflakeFormat $format = SonyflakeForm return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format); } - /** +/** * Parse Sonyflake using custom epoch in milliseconds. * * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} @@ -155,7 +155,7 @@ public static function parseWithEpoch( ]; } - /** +/** * Encodes Sonyflake bytes into one of bases: 16, 32, 36, 58, 62. * * @throws SonyflakeException @@ -165,7 +165,7 @@ public static function toBase(string $id, int $base): string return BaseEncoder::encodeBytes(self::toBytes($id), $base); } - /** +/** * Converts a Sonyflake decimal string to 8-byte binary representation. * * @throws SonyflakeException @@ -183,8 +183,51 @@ 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); + } 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]; + } - private static function assertDecodedId(string $id): string + $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'); @@ -193,7 +236,15 @@ private static function assertDecodedId(string $id): string 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 */ @@ -206,8 +257,7 @@ private static function decodeNumeric(callable $operation, ?string $customMessag } } - - /** +/** * Calculates the elapsed time in 10ms units. */ private static function elapsedTime(int $currentTime, int $startTimestamp): int @@ -215,7 +265,7 @@ private static function elapsedTime(int $currentTime, int $startTimestamp): int return intdiv($currentTime - $startTimestamp, 10); } - /** +/** * Ensures that the elapsed time does not exceed the maximum life cycle of the algorithm. * * @param int $elapsedTime The elapsed time in milliseconds. @@ -232,7 +282,7 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void } } - /** +/** * @return array{seconds:string,fraction:string,sequence:int,machine_id:int} */ private static function extractParts( @@ -256,7 +306,7 @@ private static function extractParts( ]; } - /** +/** * @throws SonyflakeException|FileLockException */ private static function generateInternal( @@ -305,75 +355,22 @@ private static function generateInternal( return self::packId($elapsedTime, $machineId, $sequence, $format); } - private static function assertMachineId(int $machineId): void +/** + * Retrieves the start timestamp. + */ + private static function getStartTimeStamp(SonyflakeFormat $format): int { - $maximum = -1 ^ (-1 << self::MACHINE_BITS); - if ($machineId < 0 || $machineId > $maximum) { - throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maximum."); - } - } - - 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); + return $format === SonyflakeFormat::UPSTREAM + ? self::UPSTREAM_DEFAULT_EPOCH + : self::DEFAULT_EPOCH; } - /** - * @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); - } 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 nowMilliseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } - private static function packId( +private static function packId( int $elapsedTime, int $machineId, int $sequence, @@ -394,27 +391,28 @@ private static function packId( ); } - /** - * Retrieves the start timestamp. - */ - private static function getStartTimeStamp(SonyflakeFormat $format): int - { - return $format === SonyflakeFormat::UPSTREAM - ? self::UPSTREAM_DEFAULT_EPOCH - : self::DEFAULT_EPOCH; - } - - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface +private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } - private static function nowMilliseconds(?GenerationContext $runtime): int - { - return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); +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( +private static function waitUntilElapsed( int $elapsedTime, int $startTimestamp, ?GenerationContext $runtime, @@ -437,7 +435,7 @@ private static function waitUntilElapsed( return $next; } - private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int +private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); @@ -456,4 +454,5 @@ private static function waitUntilWallTime(int $lastTime, ?GenerationContext $run return $currentTime; } + } From 37692a7899545112a8a84e85c1899be94a97d9d3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:47:49 +0600 Subject: [PATCH 028/107] style(uid): order support members --- src/Support/BaseEncoder.php | 133 +++++++++++++------------- src/Support/FileLock.php | 185 ++++++++++++++++++------------------ src/Support/Sparx64.php | 51 +++++----- 3 files changed, 186 insertions(+), 183 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index a5ebbfb..3d5e5b2 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -19,7 +19,7 @@ final class BaseEncoder private const int MAX_BYTE_LENGTH = 1024; - public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string +public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string { if ($encoded === '') { throw new InvalidArgumentException('Encoded value must not be empty'); @@ -33,7 +33,7 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt return self::decodeRadix($encoded, $base, $bytesLength); } - public static function encodeBytes(string $bytes, int $base): string +public static function encodeBytes(string $bytes, int $base): string { self::assertByteLength(strlen($bytes)); if ($base === 16) { @@ -48,14 +48,53 @@ public static function encodeBytes(string $bytes, int $base): string return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); } - private static function assertByteLength(int $byteLength): void +private static function alphabet(int $base): string + { + return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); + } + +/** + * @param list $bytes + * @return list + */ + private static function appendDigit(array $bytes, int $base, int $digit): array + { + $carry = $digit; + for ($index = count($bytes) - 1; $index >= 0; --$index) { + $value = ($bytes[$index] * $base) + $carry; + $bytes[$index] = $value & 0xff; + $carry = $value >> 8; + } + + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; + } + + return array_values($bytes); + } + +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 decodeHex(string $encoded, int $bytesLength): string +/** + * @param list $bytes + */ + private static function byteString(array $bytes): string + { + $decoded = ''; + foreach ($bytes as $byte) { + $decoded .= chr($byte); + } + + return $decoded; + } + +private static function decodeHex(string $encoded, int $bytesLength): string { if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { throw new InvalidArgumentException('Invalid character for base 16'); @@ -67,7 +106,7 @@ private static function decodeHex(string $encoded, int $bytesLength): string return $decoded; } - private static function decodeRadix(string $encoded, int $base, int $bytesLength): string +private static function decodeRadix(string $encoded, int $base, int $bytesLength): string { $alphabet = self::alphabet($base); $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2)); @@ -94,41 +133,41 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - /** - * @param list $bytes - * @return list +/** + * @param list $number + * @return array{0:list,1:int} */ - private static function appendDigit(array $bytes, int $base, int $digit): array + private static function divide(array $number, int $base): array { - $carry = $digit; - for ($index = count($bytes) - 1; $index >= 0; --$index) { - $value = ($bytes[$index] * $base) + $carry; - $bytes[$index] = $value & 0xff; - $carry = $value >> 8; - } - - while ($carry > 0) { - array_unshift($bytes, $carry & 0xff); - $carry >>= 8; + $quotient = []; + $remainder = 0; + foreach ($number as $byte) { + $value = ($remainder << 8) | $byte; + $digit = intdiv($value, $base); + $remainder = $value % $base; + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } } - return array_values($bytes); + return [$quotient, $remainder]; } - /** - * @param list $bytes +/** + * @param list $number */ - private static function byteString(array $bytes): string + private static function encodeRadix(array $number, int $base, string $alphabet): string { - $decoded = ''; - foreach ($bytes as $byte) { - $decoded .= chr($byte); + $encoded = ''; + while ($number !== []) { + [$number, $remainder] = self::divide($number, $base); + $encoded = $alphabet[$remainder] . $encoded; } - return $decoded; + return $encoded; } - /** +/** * @return list */ private static function unpackBytes(string $bytes): array @@ -145,42 +184,4 @@ private static function unpackBytes(string $bytes): array return $number; } - /** - * @param list $number - */ - private static function encodeRadix(array $number, int $base, string $alphabet): string - { - $encoded = ''; - while ($number !== []) { - [$number, $remainder] = self::divide($number, $base); - $encoded = $alphabet[$remainder] . $encoded; - } - - return $encoded; - } - - /** - * @param list $number - * @return array{0:list,1:int} - */ - private static function divide(array $number, int $base): array - { - $quotient = []; - $remainder = 0; - foreach ($number as $byte) { - $value = ($remainder << 8) | $byte; - $digit = intdiv($value, $base); - $remainder = $value % $base; - if ($quotient !== [] || $digit !== 0) { - $quotient[] = $digit; - } - } - - return [$quotient, $remainder]; - } - - private static function alphabet(int $base): string - { - return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); - } } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 235b391..84f0e2d 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -12,7 +12,7 @@ final class FileLock { private const int DEFAULT_TIMEOUT_MICROS = 1_000_000; - /** +/** * @return resource * @throws FileLockException */ @@ -62,7 +62,90 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } - /** +/** + * @param array $metadata + * @throws FileLockException + */ + private static function assertSafeMetadata(array $metadata, string $errorMessage): void + { + $mode = $metadata['mode']; + if (($mode & 0170000) !== 0100000) { + throw new FileLockException($errorMessage); + } + + if (function_exists('posix_geteuid') && $metadata['uid'] !== posix_geteuid()) { + throw new FileLockException($errorMessage); + } + } + +/** + * @param array $left + * @param array $right + */ + private static function assertSameFile(array $left, array $right, string $errorMessage): void + { + if ($left['dev'] !== $right['dev'] || $left['ino'] !== $right['ino']) { + throw new FileLockException($errorMessage); + } + } + +private static function changePermissions(string $path, int $permissions): bool + { + try { + return self::invokeFilesystem(static fn(): bool => chmod($path, $permissions)); + } catch (ErrorException) { + return false; + } + } + +/** + * @template T + * @param callable():T $operation + * @return T + * @throws ErrorException + */ + private static function invokeFilesystem(callable $operation): mixed + { + set_error_handler( + static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, + ); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + +/** + * @param array $before + * @return resource + */ + private static function openExisting(string $path, array $before, string $errorMessage) + { + $handle = self::openStream($path, 'r+b'); + if (!is_resource($handle)) { + throw new FileLockException($errorMessage); + } + + return self::verifyHandle($path, $handle, $before, $errorMessage); + } + +/** + * @return resource|false + */ + private static function openStream(string $path, string $mode) + { + try { + return self::invokeFilesystem(static fn() => fopen($path, $mode)); + } catch (ErrorException) { + return false; + } + } + +/** * @return resource * @throws FileLockException */ @@ -95,21 +178,19 @@ private static function openVerified(string $path, string $errorMessage) return self::verifyHandle($path, $handle, null, $errorMessage); } - /** - * @param array $before - * @return resource +/** + * @return array|false */ - private static function openExisting(string $path, array $before, string $errorMessage) + private static function pathMetadata(string $path): array|false { - $handle = self::openStream($path, 'r+b'); - if (!is_resource($handle)) { - throw new FileLockException($errorMessage); + try { + return self::invokeFilesystem(static fn(): array|false => lstat($path)); + } catch (ErrorException) { + return false; } - - return self::verifyHandle($path, $handle, $before, $errorMessage); } - /** +/** * @param resource $handle * @param array|null $before * @return resource @@ -137,84 +218,4 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri } } - /** - * @param array $left - * @param array $right - */ - private static function assertSameFile(array $left, array $right, string $errorMessage): void - { - if ($left['dev'] !== $right['dev'] || $left['ino'] !== $right['ino']) { - throw new FileLockException($errorMessage); - } - } - - /** - * @return array|false - */ - private static function pathMetadata(string $path): array|false - { - try { - return self::invokeFilesystem(static fn(): array|false => lstat($path)); - } catch (ErrorException) { - return false; - } - } - - /** - * @return resource|false - */ - private static function openStream(string $path, string $mode) - { - try { - return self::invokeFilesystem(static fn() => fopen($path, $mode)); - } catch (ErrorException) { - return false; - } - } - - private static function changePermissions(string $path, int $permissions): bool - { - try { - return self::invokeFilesystem(static fn(): bool => chmod($path, $permissions)); - } catch (ErrorException) { - return false; - } - } - - /** - * @template T - * @param callable():T $operation - * @return T - * @throws ErrorException - */ - private static function invokeFilesystem(callable $operation): mixed - { - set_error_handler( - static function (int $severity, string $message, string $file, int $line): never { - throw new ErrorException($message, 0, $severity, $file, $line); - }, - ); - - try { - return $operation(); - } finally { - restore_error_handler(); - } - } - - /** - * @param array $metadata - * @throws FileLockException - */ - private static function assertSafeMetadata(array $metadata, string $errorMessage): void - { - $mode = $metadata['mode']; - if (($mode & 0170000) !== 0100000) { - throw new FileLockException($errorMessage); - } - - if (function_exists('posix_geteuid') && $metadata['uid'] !== posix_geteuid()) { - throw new FileLockException($errorMessage); - } - } } diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php index e262224..de7b6dc 100644 --- a/src/Support/Sparx64.php +++ b/src/Support/Sparx64.php @@ -17,7 +17,7 @@ final class Sparx64 /** @var array> */ private array $subkeys; - public function __construct(#[\SensitiveParameter] string $key) +public function __construct(#[\SensitiveParameter] string $key) { if (strlen($key) !== 16) { throw new InvalidArgumentException('SPARX64 key must be exactly 16 bytes'); @@ -35,7 +35,7 @@ public function __construct(#[\SensitiveParameter] string $key) } } - public function decrypt(string $block): string +public function decrypt(string $block): string { $state = self::unpackBlock($block); $last = self::BRANCHES * self::STEPS; @@ -58,7 +58,7 @@ public function decrypt(string $block): string return self::packBlock($state); } - public function encrypt(string $block): string +public function encrypt(string $block): string { $state = self::unpackBlock($block); for ($step = 0; $step < self::STEPS; ++$step) { @@ -82,7 +82,7 @@ public function encrypt(string $block): string return self::packBlock($state); } - /** @param array $state */ +/** @param array $state */ private static function linear(array &$state): void { $temporary = self::rotateLeft16($state[0] ^ $state[1], 8); @@ -92,7 +92,7 @@ private static function linear(array &$state): void [$state[1], $state[3]] = [$state[3] & 0xffff, $state[1] & 0xffff]; } - /** @param array $state */ +/** @param array $state */ private static function linearInverse(array &$state): void { [$state[0], $state[2]] = [$state[2], $state[0]]; @@ -102,22 +102,7 @@ private static function linearInverse(array &$state): void $state[3] = ($state[3] ^ $state[1] ^ $temporary) & 0xffff; } - /** @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]), - ]; - } - - /** @param array $state */ +/** @param array $state */ private static function packBlock(array $state): string { $output = ''; @@ -128,7 +113,7 @@ private static function packBlock(array $state): string return $output; } - /** @param array $key */ +/** @param array $key */ private static function permuteKey(array &$key, int $counter): void { self::round($key[0], $key[1]); @@ -144,22 +129,38 @@ private static function permuteKey(array &$key, int $counter): void $key[1] = $seven; } - private static function rotateLeft16(int $value, int $bits): int +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 +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 +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]), + ]; + } + } From d65b8479330cc082f567252b6a6763450c2e63b0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:48:49 +0600 Subject: [PATCH 029/107] style(config): align UID 6 configuration layout --- src/Configuration/RandflakeConfig.php | 3 ++- src/Configuration/SnowflakeConfig.php | 4 ++-- src/Configuration/SonyflakeConfig.php | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index 954d769..1058c94 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -14,7 +14,8 @@ public function __construct( public int $nodeId, public int $leaseStart, public int $leaseEnd, - #[\SensitiveParameter] public string $secret, + #[\SensitiveParameter] + public string $secret, public ?SequenceProviderInterface $sequenceProvider = null, public ?GenerationContext $runtime = null, public RandflakeFormat $format = RandflakeFormat::UID, diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index 7eba0d9..21e5740 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -14,10 +14,10 @@ { use ResolvesCustomEpoch; - private ?Closure $nodeResolver; - public ?int $customEpoch; + private ?Closure $nodeResolver; + /** * @param callable():mixed|null $nodeResolver * @param DateTimeInterface|int|null $customEpoch Epoch in milliseconds or a date-time value. diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index f043c25..f494ffe 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -12,11 +12,11 @@ final readonly class SonyflakeConfig { - public ?int $customEpoch; - use ResolvesCustomEpoch; use ResolvesMachineId; + public ?int $customEpoch; + /** * @param callable():mixed|null $machineIdResolver */ From 7cc7936f93ac0ae4c4bc33e641fc43af0e8a244b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:49:27 +0600 Subject: [PATCH 030/107] style(uid): finish member and import ordering --- src/Support/GetSequence.php | 2 +- src/TBSL.php | 37 +++++++++++++++++++------------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index b4a869b..8e4e758 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -4,10 +4,10 @@ namespace Infocyph\UID\Support; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\CallbackSequenceProvider; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\InMemorySequenceProvider; -use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\PsrSimpleCacheSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Psr\SimpleCache\CacheInterface; diff --git a/src/TBSL.php b/src/TBSL.php index 0febe1e..ee1ad0d 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -23,7 +23,7 @@ final class TBSL private static int $lastTimeSequence = 0; - /** +/** * Decodes one of bases: 16, 32, 36, 58, 62 into canonical TBSL. * * @throws Exception @@ -33,7 +33,7 @@ public static function fromBase(string $encoded, int $base): string return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 10)); } - /** +/** * Converts 10-byte TBSL binary data to uppercase TBSL string. * * @throws Exception @@ -47,7 +47,7 @@ public static function fromBytes(string $bytes): string return strtoupper(bin2hex($bytes)); } - /** +/** * Generates a unique identifier using the TBSL algorithm. * * @param int $machineId 2-digit (0-99) machine identifier. Default is 0. @@ -64,7 +64,7 @@ public static function generate(int $machineId = 0, bool $sequenced = true): str ); } - /** +/** * Generates TBSL using configuration object. * * @throws Exception @@ -74,7 +74,7 @@ public static function generateRandom(int $machineId = 0): string return self::generate($machineId, false); } - public static function generateWithConfig(TBSLConfig $config): string +public static function generateWithConfig(TBSLConfig $config): string { return self::generateInternal( $config->resolveMachineId(), @@ -85,7 +85,7 @@ public static function generateWithConfig(TBSLConfig $config): string ); } - /** +/** * Checks whether a TBSL string is valid. */ public static function isValid(string $tbsl): bool @@ -93,7 +93,7 @@ public static function isValid(string $tbsl): bool return (bool) preg_match('/^[0-9A-F]{20}$/D', $tbsl); } - /** +/** * Parses a TBSL string and returns an array with its components. * * @param string $tbsl The TBSL string to parse. @@ -119,7 +119,7 @@ public static function parse(string $tbsl): array ]; } - /** +/** * Encodes TBSL bytes into one of bases: 16, 32, 36, 58, 62. * * @throws Exception @@ -129,7 +129,7 @@ public static function toBase(string $tbsl, int $base): string return BaseEncoder::encodeBytes(self::toBytes($tbsl), $base); } - /** +/** * Converts a TBSL string to 10-byte binary representation. * * @throws Exception @@ -146,7 +146,7 @@ public static function toBytes(string $tbsl): string return $bytes; } - /** +/** * @throws UIDException */ private static function assertMachineId(int $machineId): void @@ -156,7 +156,7 @@ private static function assertMachineId(int $machineId): void } } - /** +/** * @throws Exception */ private static function generateInternal( @@ -200,7 +200,12 @@ 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. * * @param int $machineId Machine identifier. @@ -250,12 +255,7 @@ private static function resolveTail( } while (true); } - private static function nowMicroseconds(?GenerationContext $runtime): int - { - return $runtime?->nowMicroseconds() ?? (int) floor(microtime(true) * 1_000_000); - } - - private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int +private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); @@ -274,4 +274,5 @@ private static function waitUntilNextTimeSequence(int $last, ?GenerationContext return $candidate; } + } From 14b61d55934f0da0230c323f423f1275a5fe90d1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:50:42 +0600 Subject: [PATCH 031/107] style(uid): restore reordered member indentation --- src/Randflake.php | 68 ++++++++++----------- src/Runtime/GenerationContext.php | 14 ++--- src/Sequence/FilesystemSequenceProvider.php | 20 +++--- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 161a743..71cc0eb 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -51,7 +51,7 @@ final class Randflake /** @var array */ private static array $sparxCache = []; -/** + /** * @throws RandflakeException */ public static function decodeString( @@ -92,7 +92,7 @@ static function (string $message, \InvalidArgumentException $exception): Randfla ); } -/** + /** * @throws RandflakeException */ public static function encodeString( @@ -111,7 +111,7 @@ public static function encodeString( ); } -/** + /** * @throws RandflakeException */ public static function fromBase( @@ -141,7 +141,7 @@ public static function fromBase( ); } -/** + /** * @throws RandflakeException */ public static function fromBytes( @@ -164,7 +164,7 @@ public static function fromBytes( ); } -/** + /** * @throws RandflakeException|FileLockException */ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret): string @@ -181,7 +181,7 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\ ); } -/** + /** * @throws RandflakeException|FileLockException */ public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret): string @@ -189,7 +189,7 @@ public static function generateString(int $nodeId, int $leaseStart, int $leaseEn return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); } -/** + /** * @throws RandflakeException|FileLockException */ public static function generateWithConfig(RandflakeConfig $config): string @@ -208,7 +208,7 @@ public static function generateWithConfig(RandflakeConfig $config): string ); } -/** + /** * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ @@ -234,7 +234,7 @@ public static function inspect( ]; } -/** + /** * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ @@ -246,7 +246,7 @@ public static function inspectString( return self::inspect(self::decodeString($id, $format), $secret, $format); } -public static function isValid( + public static function isValid( string $id, RandflakeFormat $format = RandflakeFormat::UID, ): bool { @@ -259,7 +259,7 @@ public static function isValid( && UnsignedDecimal::compare($id, '18446744073709551615') <= 0; } -/** + /** * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ @@ -285,7 +285,7 @@ public static function parse( ]; } -/** + /** * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ @@ -297,7 +297,7 @@ public static function parseString( return self::parse(self::decodeString($id, $format), $secret, $format); } -/** + /** * @throws RandflakeException */ public static function toBase( @@ -317,7 +317,7 @@ public static function toBase( ); } -/** + /** * @throws RandflakeException */ public static function toBytes( @@ -342,7 +342,7 @@ public static function toBytes( ); } -/** + /** * @return array{0:int,1:int} */ private static function allocateSequence( @@ -374,7 +374,7 @@ private static function allocateSequence( } } -private static function assertGenerationTime( + private static function assertGenerationTime( int $now, int $leaseStart, int $leaseEnd, @@ -390,7 +390,7 @@ private static function assertGenerationTime( } } -private static function encodeGeneratedPayload( + private static function encodeGeneratedPayload( int $timestamp, int $nodeId, int $sequence, @@ -407,7 +407,7 @@ private static function encodeGeneratedPayload( return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); } -/** + /** * @throws RandflakeException|FileLockException */ private static function generateInternal( @@ -453,7 +453,7 @@ private static function generateInternal( return self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format); } -/** + /** * @return array{0:int,1:int,2:int} * @throws RandflakeException */ @@ -481,7 +481,7 @@ private static function inspectBytes( return [$timestamp, $nodeId, $sequence]; } -private static function leaseContains( + private static function leaseContains( int $timestamp, int $leaseStart, int $leaseEnd, @@ -495,7 +495,7 @@ private static function leaseContains( return $timestamp >= $leaseStart && $timestamp <= $leaseEnd; } -/** + /** * @param array{timestamp:int,sequence:int}|null $last */ private static function normalizeAllocation(int $allocation, ?array $last, int $now): int @@ -517,12 +517,12 @@ private static function normalizeAllocation(int $allocation, ?array $last, int $ return $sequence; } -private static function nowSeconds(?GenerationContext $runtime): int + private static function nowSeconds(?GenerationContext $runtime): int { return $runtime?->nowSeconds() ?? time(); } -private static function packPayload(int $timestamp, int $nodeId, int $sequence): string + private static function packPayload(int $timestamp, int $nodeId, int $sequence): string { $timestampPart = $timestamp - self::EPOCH_OFFSET; $high = (($timestampPart & self::MAX_TIMESTAMP_PART) << 2) | (($nodeId >> 15) & 0x03); @@ -531,7 +531,7 @@ private static function packPayload(int $timestamp, int $nodeId, int $sequence): return pack('N2', $high, $low); } -/** + /** * Small secret-key permutation over 64-bit blocks to protect payload fields. */ private static function permute(string $block, string $secret, bool $decrypt): string @@ -561,7 +561,7 @@ private static function permute(string $block, string $secret, bool $decrypt): s return pack('N2', $left, $right); } -/** + /** * @return \ArrayObject */ private static function providerState(SequenceProviderInterface $provider): \ArrayObject @@ -581,12 +581,12 @@ private static function providerState(SequenceProviderInterface $provider): \Arr return $state; } -private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64); } -private static function roundFunction(int $value, int $key): int + private static function roundFunction(int $value, int $key): int { $mask = 0xffffffff; $value &= $mask; @@ -599,7 +599,7 @@ private static function roundFunction(int $value, int $key): int return $mixed & $mask; } -/** + /** * @return array */ private static function roundKeys(string $secret): array @@ -623,7 +623,7 @@ private static function roundKeys(string $secret): array return self::$roundKeyCache[$fingerprint] = $keys; } -private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 + private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 { $fingerprint = hash('sha256', $secret); if (isset(self::$sparxCache[$fingerprint])) { @@ -637,7 +637,7 @@ private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 return self::$sparxCache[$fingerprint] = new Sparx64($secret); } -/** + /** * @param array|false $parts * @throws RandflakeException */ @@ -655,7 +655,7 @@ private static function unpackedInt(array|false $parts, string $key): int return $value; } -/** + /** * @return array{0:int,1:int,2:int} */ private static function unpackPayload(string $payload): array @@ -671,7 +671,7 @@ private static function unpackPayload(string $payload): array return [$timestampPart + self::EPOCH_OFFSET, $nodeId, $sequence]; } -/** + /** * @throws RandflakeException */ private static function validateLeaseWindow( @@ -698,7 +698,7 @@ private static function validateLeaseWindow( } } -/** + /** * @throws RandflakeException */ private static function validateNode(int $nodeId): void @@ -708,7 +708,7 @@ private static function validateNode(int $nodeId): void } } -/** + /** * @throws RandflakeException */ private static function validateSecret(#[\SensitiveParameter] string $secret): string diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php index 4b51e39..b4df521 100644 --- a/src/Runtime/GenerationContext.php +++ b/src/Runtime/GenerationContext.php @@ -11,7 +11,7 @@ { private const int DEFAULT_WAIT_TIMEOUT_MICROS = 1_000_000; -public function __construct( + public function __construct( public ?ClockInterface $clock = null, public ?RunwireBinding $runwire = null, public int $waitTimeoutMicros = self::DEFAULT_WAIT_TIMEOUT_MICROS, @@ -21,12 +21,12 @@ public function __construct( } } -public function assertActive(): void + public function assertActive(): void { $this->runwire?->assertActive(); } -public function nowMicroseconds(): int + public function nowMicroseconds(): int { $this->assertActive(); @@ -37,17 +37,17 @@ public function nowMicroseconds(): int return (int) $this->clock->now()->format('Uu'); } -public function nowMilliseconds(): int + public function nowMilliseconds(): int { return intdiv($this->nowMicroseconds(), 1_000); } -public function nowSeconds(): int + public function nowSeconds(): int { return intdiv($this->nowMicroseconds(), 1_000_000); } -public function sleepMicroseconds(int $microseconds): void + public function sleepMicroseconds(int $microseconds): void { $this->assertActive(); if ($this->runwire !== null) { @@ -60,7 +60,7 @@ public function sleepMicroseconds(int $microseconds): void usleep($microseconds); } -public function waitDeadlineNanoseconds(): int + public function waitDeadlineNanoseconds(): int { $deadline = hrtime(true) + ($this->waitTimeoutMicros * 1_000); $runwireDeadline = $this->runwire?->deadlineNanoseconds(); diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 34e27aa..d6f4acc 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -28,7 +28,7 @@ final class FilesystemSequenceProvider implements SequenceProviderInterface private ?int $sourcePid = null; -public function __construct( + public function __construct( ?string $baseDirectory = null, private readonly string $namespace = '', private readonly ?int $lockTimeoutMicros = null, @@ -50,7 +50,7 @@ public function __construct( } } -public function next(string $type, int $machineId, int $timestamp): int + public function next(string $type, int $machineId, int $timestamp): int { $fileLocation = $this->sequenceFileLocation($type, $machineId); $this->resetAfterFork(); @@ -76,14 +76,14 @@ public function next(string $type, int $machineId, int $timestamp): int } } -private static function isCanonicalInteger(string $value): bool + private static function isCanonicalInteger(string $value): bool { return $value !== '' && ctype_digit($value) && ($value === '0' || $value[0] !== '0'); } -/** + /** * @param resource $handle */ private function allocateLocked($handle, string $fileLocation, int $timestamp): int @@ -109,7 +109,7 @@ private function allocateLocked($handle, string $fileLocation, int $timestamp): return $allocation; } -/** + /** * @param resource $handle * @return array{0:int,1:int,2:int} */ @@ -152,7 +152,7 @@ private function readState($handle): array return [(int) $timestamp, (int) $allocation, $oldLength]; } -private function resetAfterFork(): void + private function resetAfterFork(): void { $pid = (int) getmypid(); if ($pid === $this->sourcePid) { @@ -163,7 +163,7 @@ private function resetAfterFork(): void $this->reservations = []; } -private function sequenceFileLocation(string $type, int $machineId): string + private function sequenceFileLocation(string $type, int $machineId): string { $cacheKey = $type . ':' . $machineId; if (isset($this->pathCache[$cacheKey])) { @@ -182,7 +182,7 @@ private function sequenceFileLocation(string $type, int $machineId): string return $this->pathCache[$cacheKey] = $this->baseDirectory . DIRECTORY_SEPARATOR . $name; } -private function storeReservation( + private function storeReservation( string $fileLocation, int $timestamp, int $allocation, @@ -203,7 +203,7 @@ private function storeReservation( ]; } -private function takeReservedAllocation(string $fileLocation, int $timestamp): ?int + private function takeReservedAllocation(string $fileLocation, int $timestamp): ?int { $reservation = $this->reservations[$fileLocation] ?? null; if ( @@ -224,7 +224,7 @@ private function takeReservedAllocation(string $fileLocation, int $timestamp): ? return $allocation; } -/** + /** * @param resource $handle */ private function writeState($handle, string $state, int $oldLength): void From ee3da26f632c6dce9617ad43b5c09c54580b76b1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:51:08 +0600 Subject: [PATCH 032/107] style(uid): restore generator member indentation --- .../PsrSimpleCacheSequenceProvider.php | 22 ++++----- src/Snowflake.php | 44 ++++++++--------- src/Sonyflake.php | 48 +++++++++---------- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 2ca0033..0fbbdae 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -20,7 +20,7 @@ final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface /** @var array */ private array $observedState = []; -/** + /** * @param callable(string, callable():int):mixed|null $synchronizer */ public function __construct( @@ -45,7 +45,7 @@ public function __construct( $this->synchronizer = $synchronizer ? $synchronizer(...) : null; } -/** + /** * @throws FileLockException */ public function next(string $type, int $machineId, int $timestamp): int @@ -65,7 +65,7 @@ public function next(string $type, int $machineId, int $timestamp): int } } -/** + /** * @param array{timestamp:int,sequence:int}|null $state * @param array{timestamp:int,sequence:int}|null $observed */ @@ -82,7 +82,7 @@ private static function assertNotRegressed(?array $state, ?array $observed, stri } } -/** + /** * @param array{timestamp:int,sequence:int}|null $state */ private static function nextSequence(?array $state, int $timestamp, string $key): int @@ -107,7 +107,7 @@ private static function nextSequence(?array $state, int $timestamp, string $key) return $state['sequence'] + 1; } -/** + /** * @return resource * @throws FileLockException */ @@ -124,7 +124,7 @@ private function acquireLock(string $key) ); } -private function key(string $type, int $machineId): string + private function key(string $type, int $machineId): string { if (preg_match('/^[A-Za-z0-9_.]+$/D', $type) !== 1) { throw new InvalidArgumentException('Sequence type contains characters not guaranteed by PSR-16'); @@ -138,7 +138,7 @@ 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): int { $state = $this->normalizeState($this->cache->get($key), $key); $observed = $this->observedState[$key] ?? null; @@ -158,7 +158,7 @@ private function nextFromCacheState(string $key, int $timestamp): int return $sequence; } -private function nextSafely(string $key, int $timestamp): int + private function nextSafely(string $key, int $timestamp): int { try { return $this->nextFromCacheState($key, $timestamp); @@ -169,7 +169,7 @@ private function nextSafely(string $key, int $timestamp): int } } -private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp): int + private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp): int { try { $sequence = $synchronizer( @@ -189,7 +189,7 @@ private function nextSynchronized(Closure $synchronizer, string $key, int $times return $sequence; } -/** + /** * @return array{timestamp:int,sequence:int}|null */ private function normalizeState(mixed $state, string $key): ?array @@ -215,7 +215,7 @@ private function normalizeState(mixed $state, string $key): ?array return ['timestamp' => $stateTimestamp, 'sequence' => $stateSequence]; } -private function storageFailure(string $key, Throwable $exception): FileLockException + private function storageFailure(string $key, Throwable $exception): FileLockException { return new FileLockException( 'Failed to read/write sequence state from PSR cache for key: ' . $key, diff --git a/src/Snowflake.php b/src/Snowflake.php index b069109..6f6a7eb 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -38,7 +38,7 @@ final class Snowflake /** @var \WeakMap>|null */ private static ?\WeakMap $lastStateByProvider = null; -/** + /** * Decodes one of bases: 16, 32, 36, 58, 62 into Snowflake decimal. * * @throws SnowflakeException @@ -48,7 +48,7 @@ public static function fromBase(string $encoded, int $base): string return self::decodeNumericBase($encoded, $base); } -/** + /** * Converts 8-byte Snowflake binary data to decimal string. * * @throws SnowflakeException @@ -58,7 +58,7 @@ public static function fromBytes(string $bytes): string return self::decodeNumericBytes($bytes); } -/** + /** * Generates a unique snowflake ID. * * @param int $datacenter The ID of the datacenter (default: 0) @@ -76,7 +76,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string ); } -/** + /** * Generates Snowflake using configuration object. * * @throws SnowflakeException|FileLockException @@ -96,7 +96,7 @@ public static function generateWithConfig(SnowflakeConfig $config): string ); } -/** + /** * Checks whether a Snowflake ID string has a valid numeric shape. */ public static function isValid(string $id): bool @@ -106,7 +106,7 @@ public static function isValid(string $id): bool && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } -/** + /** * Parse the given ID into components. * * @param string $id The ID to parse. @@ -121,7 +121,7 @@ public static function parse(string $id): array ); } -/** + /** * Parse Snowflake ID using a custom epoch in milliseconds. * * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} @@ -150,7 +150,7 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array ]; } -/** + /** * Encodes Snowflake bytes into one of bases: 16, 32, 36, 58, 62. * * @throws SnowflakeException @@ -160,7 +160,7 @@ public static function toBase(string $id, int $base): string return BaseEncoder::encodeBytes(self::toBytes($id), $base); } -/** + /** * Converts a Snowflake decimal string to 8-byte binary representation. * * @throws SnowflakeException @@ -170,7 +170,7 @@ public static function toBytes(string $id): string return self::encodeNumericBytes($id); } -private static function assertDecodedId(string $id): string + private static function assertDecodedId(string $id): string { if (!self::isValid($id)) { throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); @@ -179,7 +179,7 @@ private static function assertDecodedId(string $id): string return $id; } -/** + /** * @throws SnowflakeException */ private static function assertNodeIds(int $datacenter, int $workerId): void @@ -196,7 +196,7 @@ private static function assertNodeIds(int $datacenter, int $workerId): void } } -/** + /** * @throws SnowflakeException */ private static function assertTimestampRange(int $currentTime, int $startTimestamp): void @@ -212,7 +212,7 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta } } -private static function decodeNumericBase(string $encoded, int $base): string + private static function decodeNumericBase(string $encoded, int $base): string { $id = NumericConversion::decimalFromBase( $encoded, @@ -224,7 +224,7 @@ private static function decodeNumericBase(string $encoded, int $base): string return self::assertDecodedId($id); } -private static function decodeNumericBytes(string $bytes): string + private static function decodeNumericBytes(string $bytes): string { $id = NumericConversion::decimalFromBytes( $bytes, @@ -236,7 +236,7 @@ private static function decodeNumericBytes(string $bytes): string return self::assertDecodedId($id); } -private static function encodeNumericBytes(string $id): string + private static function encodeNumericBytes(string $id): string { return NumericConversion::bytesFromDecimal( $id, @@ -248,7 +248,7 @@ private static function encodeNumericBytes(string $id): string ); } -/** + /** * @throws SnowflakeException|FileLockException */ private static function generateInternal( @@ -322,7 +322,7 @@ private static function generateInternal( | ($sequence)); } -/** + /** * Retrieves the start timestamp. */ private static function getStartTimeStamp(): int @@ -330,7 +330,7 @@ private static function getStartTimeStamp(): int return self::DEFAULT_EPOCH; } -/** + /** * @return array{0:int, 1:int} * @throws FileLockException|SnowflakeException */ @@ -375,17 +375,17 @@ private static function nextSequenceAtValidTimestamp( } } -private static function nowMilliseconds(?GenerationContext $runtime): int + private static function nowMilliseconds(?GenerationContext $runtime): int { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } -private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } -/** + /** * @return array{0:string,1:string} */ private static function timestampParts(int $timestamp): array @@ -393,7 +393,7 @@ private static function timestampParts(int $timestamp): array return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } -private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int + private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 193b2cd..6bf4e36 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -39,7 +39,7 @@ final class Sonyflake /** @var \WeakMap>|null */ private static ?\WeakMap $lastWallTimeByProvider = null; -/** + /** * Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal. * * @throws SonyflakeException @@ -54,7 +54,7 @@ public static function fromBase(string $encoded, int $base): string return self::assertDecodedId($id); } -/** + /** * Converts 8-byte Sonyflake binary data to decimal string. * * @throws SonyflakeException @@ -69,7 +69,7 @@ public static function fromBytes(string $bytes): string return self::assertDecodedId($id); } -/** + /** * Generates a unique identifier using the SonyFlake algorithm. * * @param int $machineId The machine identifier. Must be between 0 and the maximum machine ID. @@ -86,7 +86,7 @@ public static function generate(int $machineId = 0): string ); } -/** + /** * Generates Sonyflake using configuration object. * * @throws SonyflakeException|FileLockException @@ -103,7 +103,7 @@ public static function generateWithConfig(SonyflakeConfig $config): string ); } -/** + /** * Checks whether a Sonyflake ID string has a valid numeric shape. */ public static function isValid(string $id): bool @@ -113,7 +113,7 @@ public static function isValid(string $id): bool && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } -/** + /** * Parse the given ID into components. * * @param string $id The ID to parse. @@ -125,7 +125,7 @@ public static function parse(string $id, SonyflakeFormat $format = SonyflakeForm return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format); } -/** + /** * Parse Sonyflake using custom epoch in milliseconds. * * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} @@ -155,7 +155,7 @@ public static function parseWithEpoch( ]; } -/** + /** * Encodes Sonyflake bytes into one of bases: 16, 32, 36, 58, 62. * * @throws SonyflakeException @@ -165,7 +165,7 @@ public static function toBase(string $id, int $base): string return BaseEncoder::encodeBytes(self::toBytes($id), $base); } -/** + /** * Converts a Sonyflake decimal string to 8-byte binary representation. * * @throws SonyflakeException @@ -183,7 +183,7 @@ public static function toBytes(string $id): string ); } -/** + /** * @return array{0:int,1:int} */ private static function allocateSequence( @@ -227,7 +227,7 @@ private static function allocateSequence( } } -private static function assertDecodedId(string $id): string + private static function assertDecodedId(string $id): string { if (!self::isValid($id)) { throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); @@ -236,7 +236,7 @@ private static function assertDecodedId(string $id): string return $id; } -private static function assertMachineId(int $machineId): void + private static function assertMachineId(int $machineId): void { $maximum = -1 ^ (-1 << self::MACHINE_BITS); if ($machineId < 0 || $machineId > $maximum) { @@ -244,7 +244,7 @@ private static function assertMachineId(int $machineId): void } } -/** + /** * @param callable():string $operation * @throws SonyflakeException */ @@ -257,7 +257,7 @@ private static function decodeNumeric(callable $operation, ?string $customMessag } } -/** + /** * Calculates the elapsed time in 10ms units. */ private static function elapsedTime(int $currentTime, int $startTimestamp): int @@ -265,7 +265,7 @@ private static function elapsedTime(int $currentTime, int $startTimestamp): int return intdiv($currentTime - $startTimestamp, 10); } -/** + /** * Ensures that the elapsed time does not exceed the maximum life cycle of the algorithm. * * @param int $elapsedTime The elapsed time in milliseconds. @@ -282,7 +282,7 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void } } -/** + /** * @return array{seconds:string,fraction:string,sequence:int,machine_id:int} */ private static function extractParts( @@ -306,7 +306,7 @@ private static function extractParts( ]; } -/** + /** * @throws SonyflakeException|FileLockException */ private static function generateInternal( @@ -355,7 +355,7 @@ private static function generateInternal( return self::packId($elapsedTime, $machineId, $sequence, $format); } -/** + /** * Retrieves the start timestamp. */ private static function getStartTimeStamp(SonyflakeFormat $format): int @@ -365,12 +365,12 @@ private static function getStartTimeStamp(SonyflakeFormat $format): int : self::DEFAULT_EPOCH; } -private static function nowMilliseconds(?GenerationContext $runtime): int + private static function nowMilliseconds(?GenerationContext $runtime): int { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } -private static function packId( + private static function packId( int $elapsedTime, int $machineId, int $sequence, @@ -391,12 +391,12 @@ private static function packId( ); } -private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } -private static function resolveWallTime( + private static function resolveWallTime( int $currentTime, int $lastWallTime, ClockBackwardPolicy $policy, @@ -412,7 +412,7 @@ private static function resolveWallTime( return self::waitUntilWallTime($lastWallTime, $runtime); } -private static function waitUntilElapsed( + private static function waitUntilElapsed( int $elapsedTime, int $startTimestamp, ?GenerationContext $runtime, @@ -435,7 +435,7 @@ private static function waitUntilElapsed( return $next; } -private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int + private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); From c05f9a096898493b1bb00359838ab85d819ee0ec Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:51:41 +0600 Subject: [PATCH 033/107] style(uid): restore support member indentation --- src/Support/BaseEncoder.php | 22 +++++++++++----------- src/Support/FileLock.php | 20 ++++++++++---------- src/Support/Sparx64.php | 22 +++++++++++----------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 3d5e5b2..75a6c5d 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -19,7 +19,7 @@ final class BaseEncoder private const int MAX_BYTE_LENGTH = 1024; -public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string + public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string { if ($encoded === '') { throw new InvalidArgumentException('Encoded value must not be empty'); @@ -33,7 +33,7 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt return self::decodeRadix($encoded, $base, $bytesLength); } -public static function encodeBytes(string $bytes, int $base): string + public static function encodeBytes(string $bytes, int $base): string { self::assertByteLength(strlen($bytes)); if ($base === 16) { @@ -48,12 +48,12 @@ public static function encodeBytes(string $bytes, int $base): string return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); } -private static function alphabet(int $base): string + private static function alphabet(int $base): string { return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); } -/** + /** * @param list $bytes * @return list */ @@ -74,14 +74,14 @@ private static function appendDigit(array $bytes, int $base, int $digit): array return array_values($bytes); } -private static function assertByteLength(int $byteLength): void + 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'); } } -/** + /** * @param list $bytes */ private static function byteString(array $bytes): string @@ -94,7 +94,7 @@ private static function byteString(array $bytes): string return $decoded; } -private static function decodeHex(string $encoded, int $bytesLength): string + private static function decodeHex(string $encoded, int $bytesLength): string { if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { throw new InvalidArgumentException('Invalid character for base 16'); @@ -106,7 +106,7 @@ private static function decodeHex(string $encoded, int $bytesLength): string return $decoded; } -private static function decodeRadix(string $encoded, int $base, int $bytesLength): string + private static function decodeRadix(string $encoded, int $base, int $bytesLength): string { $alphabet = self::alphabet($base); $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2)); @@ -133,7 +133,7 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } -/** + /** * @param list $number * @return array{0:list,1:int} */ @@ -153,7 +153,7 @@ private static function divide(array $number, int $base): array return [$quotient, $remainder]; } -/** + /** * @param list $number */ private static function encodeRadix(array $number, int $base, string $alphabet): string @@ -167,7 +167,7 @@ private static function encodeRadix(array $number, int $base, string $alphabet): return $encoded; } -/** + /** * @return list */ private static function unpackBytes(string $bytes): array diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 84f0e2d..5f6ab0d 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -12,7 +12,7 @@ final class FileLock { private const int DEFAULT_TIMEOUT_MICROS = 1_000_000; -/** + /** * @return resource * @throws FileLockException */ @@ -62,7 +62,7 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } -/** + /** * @param array $metadata * @throws FileLockException */ @@ -78,7 +78,7 @@ private static function assertSafeMetadata(array $metadata, string $errorMessage } } -/** + /** * @param array $left * @param array $right */ @@ -89,7 +89,7 @@ private static function assertSameFile(array $left, array $right, string $errorM } } -private static function changePermissions(string $path, int $permissions): bool + private static function changePermissions(string $path, int $permissions): bool { try { return self::invokeFilesystem(static fn(): bool => chmod($path, $permissions)); @@ -98,7 +98,7 @@ private static function changePermissions(string $path, int $permissions): bool } } -/** + /** * @template T * @param callable():T $operation * @return T @@ -119,7 +119,7 @@ static function (int $severity, string $message, string $file, int $line): never } } -/** + /** * @param array $before * @return resource */ @@ -133,7 +133,7 @@ private static function openExisting(string $path, array $before, string $errorM return self::verifyHandle($path, $handle, $before, $errorMessage); } -/** + /** * @return resource|false */ private static function openStream(string $path, string $mode) @@ -145,7 +145,7 @@ private static function openStream(string $path, string $mode) } } -/** + /** * @return resource * @throws FileLockException */ @@ -178,7 +178,7 @@ private static function openVerified(string $path, string $errorMessage) return self::verifyHandle($path, $handle, null, $errorMessage); } -/** + /** * @return array|false */ private static function pathMetadata(string $path): array|false @@ -190,7 +190,7 @@ private static function pathMetadata(string $path): array|false } } -/** + /** * @param resource $handle * @param array|null $before * @return resource diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php index de7b6dc..2726f08 100644 --- a/src/Support/Sparx64.php +++ b/src/Support/Sparx64.php @@ -17,7 +17,7 @@ final class Sparx64 /** @var array> */ private array $subkeys; -public function __construct(#[\SensitiveParameter] string $key) + public function __construct(#[\SensitiveParameter] string $key) { if (strlen($key) !== 16) { throw new InvalidArgumentException('SPARX64 key must be exactly 16 bytes'); @@ -35,7 +35,7 @@ public function __construct(#[\SensitiveParameter] string $key) } } -public function decrypt(string $block): string + public function decrypt(string $block): string { $state = self::unpackBlock($block); $last = self::BRANCHES * self::STEPS; @@ -58,7 +58,7 @@ public function decrypt(string $block): string return self::packBlock($state); } -public function encrypt(string $block): string + public function encrypt(string $block): string { $state = self::unpackBlock($block); for ($step = 0; $step < self::STEPS; ++$step) { @@ -82,7 +82,7 @@ public function encrypt(string $block): string return self::packBlock($state); } -/** @param array $state */ + /** @param array $state */ private static function linear(array &$state): void { $temporary = self::rotateLeft16($state[0] ^ $state[1], 8); @@ -92,7 +92,7 @@ private static function linear(array &$state): void [$state[1], $state[3]] = [$state[3] & 0xffff, $state[1] & 0xffff]; } -/** @param array $state */ + /** @param array $state */ private static function linearInverse(array &$state): void { [$state[0], $state[2]] = [$state[2], $state[0]]; @@ -102,7 +102,7 @@ private static function linearInverse(array &$state): void $state[3] = ($state[3] ^ $state[1] ^ $temporary) & 0xffff; } -/** @param array $state */ + /** @param array $state */ private static function packBlock(array $state): string { $output = ''; @@ -113,7 +113,7 @@ private static function packBlock(array $state): string return $output; } -/** @param array $key */ + /** @param array $key */ private static function permuteKey(array &$key, int $counter): void { self::round($key[0], $key[1]); @@ -129,26 +129,26 @@ private static function permuteKey(array &$key, int $counter): void $key[1] = $seven; } -private static function rotateLeft16(int $value, int $bits): int + 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 + 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 + private static function roundInverse(int &$left, int &$right): void { $right = self::rotateLeft16($right ^ $left, 14); $left = self::rotateLeft16(($left - $right) & 0xffff, 7); } -/** @return array */ + /** @return array */ private static function unpackBlock(string $block): array { if (strlen($block) !== 8) { From 0287d6f0a0e811147a68b129f398c92bdc398a1c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:52:06 +0600 Subject: [PATCH 034/107] style(tbsl): restore member indentation --- src/TBSL.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/TBSL.php b/src/TBSL.php index ee1ad0d..679ea7e 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -23,7 +23,7 @@ final class TBSL private static int $lastTimeSequence = 0; -/** + /** * Decodes one of bases: 16, 32, 36, 58, 62 into canonical TBSL. * * @throws Exception @@ -33,7 +33,7 @@ public static function fromBase(string $encoded, int $base): string return self::fromBytes(BaseEncoder::decodeToBytes($encoded, $base, 10)); } -/** + /** * Converts 10-byte TBSL binary data to uppercase TBSL string. * * @throws Exception @@ -47,7 +47,7 @@ public static function fromBytes(string $bytes): string return strtoupper(bin2hex($bytes)); } -/** + /** * Generates a unique identifier using the TBSL algorithm. * * @param int $machineId 2-digit (0-99) machine identifier. Default is 0. @@ -64,7 +64,7 @@ public static function generate(int $machineId = 0, bool $sequenced = true): str ); } -/** + /** * Generates TBSL using configuration object. * * @throws Exception @@ -74,7 +74,7 @@ public static function generateRandom(int $machineId = 0): string return self::generate($machineId, false); } -public static function generateWithConfig(TBSLConfig $config): string + public static function generateWithConfig(TBSLConfig $config): string { return self::generateInternal( $config->resolveMachineId(), @@ -85,7 +85,7 @@ public static function generateWithConfig(TBSLConfig $config): string ); } -/** + /** * Checks whether a TBSL string is valid. */ public static function isValid(string $tbsl): bool @@ -93,7 +93,7 @@ public static function isValid(string $tbsl): bool return (bool) preg_match('/^[0-9A-F]{20}$/D', $tbsl); } -/** + /** * Parses a TBSL string and returns an array with its components. * * @param string $tbsl The TBSL string to parse. @@ -119,7 +119,7 @@ public static function parse(string $tbsl): array ]; } -/** + /** * Encodes TBSL bytes into one of bases: 16, 32, 36, 58, 62. * * @throws Exception @@ -129,7 +129,7 @@ public static function toBase(string $tbsl, int $base): string return BaseEncoder::encodeBytes(self::toBytes($tbsl), $base); } -/** + /** * Converts a TBSL string to 10-byte binary representation. * * @throws Exception @@ -146,7 +146,7 @@ public static function toBytes(string $tbsl): string return $bytes; } -/** + /** * @throws UIDException */ private static function assertMachineId(int $machineId): void @@ -156,7 +156,7 @@ private static function assertMachineId(int $machineId): void } } -/** + /** * @throws Exception */ private static function generateInternal( @@ -200,12 +200,12 @@ private static function generateInternal( )); } -private static function nowMicroseconds(?GenerationContext $runtime): int + 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. * * @param int $machineId Machine identifier. @@ -255,7 +255,7 @@ private static function resolveTail( } while (true); } -private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int + private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); From 64b03db22809ae07492254eede0b8e3120f4ebbe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:55:30 +0600 Subject: [PATCH 035/107] docs(uid): document runtime, coordination, and 6.0 migration --- docs/framework-integration.rst | 76 +++++++++++++++++++++++------- docs/migration-6.0.rst | 85 ++++++++++++++++++++++++++++++++++ docs/sequence-providers.rst | 56 ++++++++++++++++------ 3 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 docs/migration-6.0.rst diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index 4cd2772..2a9eae7 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -6,35 +6,75 @@ 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 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 ---------------------------------- + Date: Tue, 6 Oct 2026 22:56:08 +0600 Subject: [PATCH 036/107] docs(uid): correct UUID and random ID contracts --- docs/index.rst | 1 + docs/random-ids.rst | 8 +++++--- docs/uuid.rst | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index d0a05e4..f6d5980 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,6 +24,7 @@ It supports: installation quickstart + migration-6.0 id-facade .. toctree:: diff --git a/docs/random-ids.rst b/docs/random-ids.rst index 3f478a5..4a85423 100644 --- a/docs/random-ids.rst +++ b/docs/random-ids.rst @@ -4,9 +4,11 @@ Random and Compact IDs RandomId and NanoID ------------------- -``RandomId`` and ``NanoID`` use rejection sampling over PHP's CSPRNG, avoiding -modulo bias for every valid single-byte alphabet size from 2 through 256. -Lengths are limited to 1024 bytes. Alphabets must contain unique symbols. +``RandomId`` uses rejection sampling over PHP's CSPRNG for caller-supplied +single-byte alphabets, avoiding modulo bias for valid alphabet sizes from 2 +through 256. ``NanoID`` uses a fixed Base64url alphabet and derives the requested +length directly from CSPRNG bytes. Generated lengths are capped at 1024. +RandomId alphabets must contain unique symbols. .. code-block:: php diff --git a/docs/uuid.rst b/docs/uuid.rst index a55c0fa..5320429 100644 --- a/docs/uuid.rst +++ b/docs/uuid.rst @@ -36,8 +36,8 @@ Generation Node-Aware Versions ------------------- -Versions ``v1``, ``v6``, ``v7``, and ``v8`` accept an optional node. -If omitted, UID generates one. +Versions ``v1``, ``v6``, and ``v8`` accept an optional node. If omitted, UID +generates one. UUIDv7 is timestamp/randomness based and does not accept a node. .. code-block:: php @@ -47,7 +47,9 @@ If omitted, UID generates one. $node = UUID::getNode(); // 12 hex chars - $uuid = UUID::v7(null, $node); + $v1 = UUID::v1($node); + $v6 = UUID::v6($node); + $v8 = UUID::v8($node); Canonical Utilities ------------------- @@ -85,7 +87,6 @@ millisecond field instead of truncating them. ``UUID::parse()`` returns: -- ``isValid`` (bool) - ``version`` (int|null) - ``variant`` (string|null) - ``time`` (DateTimeInterface|null) From 4999d25028ec72c0fdc3752d924ab1ec468b8266 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 6 Oct 2026 22:57:05 +0600 Subject: [PATCH 037/107] docs(uid): document explicit compatibility formats --- docs/compatibility.rst | 7 +++++-- docs/randflake.rst | 34 +++++++++++++++++++++++++++++++--- docs/sonyflake.rst | 28 ++++++++++++++++++++-------- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 7c75299..220ecd5 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -10,8 +10,10 @@ 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. - TBSL is a project-specific 10-byte, uppercase hexadecimal format. - KSUID and XID retain their standard fixed-length text and binary layouts. @@ -63,5 +65,6 @@ Runtime Requirements - PHP 8.4 or newer on a 64-bit runtime. - The ctype extension is required. +- Runwire 2.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/randflake.rst b/docs/randflake.rst index 4e5d5ac..be2751c 100644 --- a/docs/randflake.rst +++ b/docs/randflake.rst @@ -6,8 +6,11 @@ Class: ``Infocyph\\UID\\Randflake`` Overview -------- -Randflake is a lease-bound 64-bit ID family whose payload fields are obscured by -a reversible keyed permutation. +Randflake is a lease-bound 64-bit ID family. ``RandflakeFormat::UID`` remains the +default and preserves UID's legacy reversible Feistel representation. +``RandflakeFormat::UPSTREAM`` uses the pinned upstream SPARX64 representation, +byte order, signed decimal form and Base32hex text contract. Raw IDs do not carry +a format discriminator, so applications using both modes must store one externally. Layout before permutation: @@ -46,6 +49,9 @@ Use ``Infocyph\\UID\\Configuration\\RandflakeConfig``: - ``leaseStart`` and ``leaseEnd`` (Unix seconds) - ``secret`` (exactly 16 bytes) - optional ``sequenceProvider`` +- optional ``runtime`` (``GenerationContext``) +- ``format`` (UID legacy by default) +- optional ``leaseEndExclusive`` for upstream mode .. code-block:: php @@ -63,6 +69,26 @@ Use ``Infocyph\\UID\\Configuration\\RandflakeConfig``: $id = Randflake::generateWithConfig($config); + $upstream = Randflake::generateWithConfig( + new RandflakeConfig( + nodeId: 42, + leaseStart: time() - 5, + leaseEnd: time() + 300, + secret: 'super-secret-key', + format: \Infocyph\UID\Enums\RandflakeFormat::UPSTREAM, + leaseEndExclusive: time() + 301, + ), + ); + +Lease Semantics +--------------- + +UID legacy mode keeps the existing inclusive ``leaseEnd`` contract. Upstream +mode uses an exclusive end. If ``leaseEndExclusive`` is omitted, +``RandflakeConfig`` translates the inclusive value to ``leaseEnd + 1``. Every +resampled retry revalidates lease, lifetime and rollback constraints before a +new allocation is consumed. + Validation and Parsing ---------------------- @@ -95,7 +121,9 @@ Binary and Alternate Bases - ``Randflake::toBase($id, $base)`` / ``Randflake::fromBase($encoded, $base)`` - ``Randflake::encodeString($id)`` / ``Randflake::decodeString($stringId)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. Pass the same explicit +format to conversion/parsing APIs that was used to generate the ID. In upstream +mode base 32 follows the upstream Base32hex representation. Exception Types --------------- diff --git a/docs/sonyflake.rst b/docs/sonyflake.rst index 69fb7a4..62f7967 100644 --- a/docs/sonyflake.rst +++ b/docs/sonyflake.rst @@ -6,11 +6,11 @@ Class: ``Infocyph\\UID\\Sonyflake`` Bit Layout ---------- -Sonyflake uses a 64-bit layout: - -- 39 bits elapsed time in 10ms units from custom epoch -- 16 bits machine ID -- 8 bits sequence +Sonyflake supports two explicit 64-bit formats. ``SonyflakeFormat::UID`` remains +the default for stored compatibility and uses 39-bit time / 16-bit machine / +8-bit sequence. ``SonyflakeFormat::UPSTREAM`` uses the upstream 39-bit time / +8-bit sequence / 16-bit machine layout. The two modes are not inferred from an +unlabelled integer. Generation ---------- @@ -33,6 +33,8 @@ Use ``Infocyph\\UID\\Configuration\\SonyflakeConfig`` for: - custom epoch - custom sequence provider - clock-backward policy +- optional ``GenerationContext`` for clock/Runwire wait policy +- explicit ``SonyflakeFormat`` .. code-block:: php @@ -44,6 +46,13 @@ Use ``Infocyph\\UID\\Configuration\\SonyflakeConfig`` for: $config = new SonyflakeConfig(machineId: 42); $id = Sonyflake::generateWithConfig($config); + $upstream = Sonyflake::generateWithConfig( + new SonyflakeConfig( + machineId: 42, + format: \Infocyph\UID\Enums\SonyflakeFormat::UPSTREAM, + ), + ); + Validation and Parsing ---------------------- @@ -65,10 +74,13 @@ Validation and Parsing Custom Epoch APIs ----------------- -- ``Sonyflake::parseWithEpoch($id, $epochMs)`` +- ``Sonyflake::parse($id, $format)`` +- ``Sonyflake::parseWithEpoch($id, $epochMs, $format)`` -The epoch is immutable global-domain configuration: supply it through a config -and retain it when parsing. Changing an epoch creates a different ID domain. +The epoch is immutable domain configuration: supply it through a config and +retain both epoch and format metadata when parsing. The upstream mode uses its +upstream default epoch unless a custom epoch is explicitly configured. Changing +format or epoch creates a different ID domain. Binary and Alternate Bases -------------------------- From 2fd8041c1f6aa2ce5ec430e746e671fdaefc2b94 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:06:53 +0600 Subject: [PATCH 038/107] fix(qa): normalize generator and lock quality --- src/Randflake.php | 3 +-- src/Sonyflake.php | 1 - src/Support/FileLock.php | 19 +++++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 71cc0eb..2c7005f 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -7,9 +7,9 @@ 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\Enums\RandflakeFormat; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\FilesystemSequenceProvider; @@ -719,5 +719,4 @@ private static function validateSecret(#[\SensitiveParameter] string $secret): s return $secret; } - } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 6bf4e36..aad7b1e 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -454,5 +454,4 @@ private static function waitUntilWallTime(int $lastTime, ?GenerationContext $run return $currentTime; } - } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 5f6ab0d..2c819de 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -24,12 +24,7 @@ public static function acquire( ?GenerationContext $runtime = null, ) { $handle = self::openVerified($path, $openErrorMessage); - $timeout = $timeoutMicros; - if ($timeout === null) { - $timeout = $runtime === null - ? self::DEFAULT_TIMEOUT_MICROS - : $runtime->waitTimeoutMicros; - } + $timeout = $timeoutMicros ?? self::runtimeTimeout($runtime); $deadline = hrtime(true) + ($timeout * 1_000); $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); if ($runtimeDeadline !== null) { @@ -178,9 +173,6 @@ private static function openVerified(string $path, string $errorMessage) return self::verifyHandle($path, $handle, null, $errorMessage); } - /** - * @return array|false - */ private static function pathMetadata(string $path): array|false { try { @@ -190,6 +182,14 @@ private static function pathMetadata(string $path): array|false } } + /** + * @return array|false + */ + private static function runtimeTimeout(?GenerationContext $runtime): int + { + return $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + } + /** * @param resource $handle * @param array|null $before @@ -217,5 +217,4 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri throw $exception; } } - } From b1e3fec66de73e5828fc41361bdb0aa3832358e4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:07:26 +0600 Subject: [PATCH 039/107] style(uid): normalize coordinated generator layout --- src/Runtime/GenerationContext.php | 1 - src/Sequence/FilesystemSequenceProvider.php | 1 - src/Sequence/PsrSimpleCacheSequenceProvider.php | 1 - src/Snowflake.php | 1 - 4 files changed, 4 deletions(-) diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php index b4df521..650b4b8 100644 --- a/src/Runtime/GenerationContext.php +++ b/src/Runtime/GenerationContext.php @@ -67,5 +67,4 @@ public function waitDeadlineNanoseconds(): int return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline); } - } diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index d6f4acc..c655c6d 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -241,5 +241,4 @@ private function writeState($handle, string $state, int $oldLength): void fflush($handle) || throw new FileLockException('Unable to flush sequence state'); } - } diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 0fbbdae..c5c3e7d 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -223,5 +223,4 @@ private function storageFailure(string $key, Throwable $exception): FileLockExce $exception, ); } - } diff --git a/src/Snowflake.php b/src/Snowflake.php index 6f6a7eb..85b479c 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -412,5 +412,4 @@ private static function waitUntil(int $timestamp, ?GenerationContext $runtime): return $now; } - } From f71cd166e796f2641a42881b6b6f2909a09cb801 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:08:02 +0600 Subject: [PATCH 040/107] style(uid): normalize support and configuration layout --- src/Configuration/RandflakeConfig.php | 1 - src/Support/BaseEncoder.php | 1 - src/Support/Sparx64.php | 1 - src/TBSL.php | 1 - 4 files changed, 4 deletions(-) diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index 1058c94..6f32f5f 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -31,4 +31,3 @@ public function resolveLeaseEndExclusive(): int return $this->leaseEnd + 1; } } - diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 75a6c5d..7f06d65 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -183,5 +183,4 @@ private static function unpackBytes(string $bytes): array return $number; } - } diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php index 2726f08..78f6705 100644 --- a/src/Support/Sparx64.php +++ b/src/Support/Sparx64.php @@ -162,5 +162,4 @@ private static function unpackBlock(string $block): array (ord($block[6]) << 8) | ord($block[7]), ]; } - } diff --git a/src/TBSL.php b/src/TBSL.php index 679ea7e..4f791f1 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -274,5 +274,4 @@ private static function waitUntilNextTimeSequence(int $last, ?GenerationContext return $candidate; } - } From c92445099ae69268059c1d601dcac4f7033d1762 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:10:32 +0600 Subject: [PATCH 041/107] fix(sequence): bound persistent provider domains --- src/Sequence/InMemorySequenceProvider.php | 6 ++ .../PsrSimpleCacheSequenceProvider.php | 5 + tests/SequenceDomainLimitTest.php | 91 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/SequenceDomainLimitTest.php 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 c5c3e7d..40a7570 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -15,6 +15,8 @@ final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface { + private const int MAX_OBSERVED_DOMAINS = 1024; + private readonly ?Closure $synchronizer; /** @var array */ @@ -51,6 +53,9 @@ public function __construct( public function next(string $type, int $machineId, int $timestamp): int { $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) { return $this->nextSynchronized($this->synchronizer, $key, $timestamp); 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'); +}); From 9e65b13cfd90391a99955e0287a9e89a290dbea4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:11:32 +0600 Subject: [PATCH 042/107] fix(runtime): bound generator safety domains --- src/Randflake.php | 22 ++++--- src/Snowflake.php | 29 ++++++++- src/Sonyflake.php | 37 ++++++++--- tests/PersistentDomainLimitTest.php | 95 +++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 tests/PersistentDomainLimitTest.php diff --git a/src/Randflake.php b/src/Randflake.php index 2c7005f..8338c38 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -42,6 +42,8 @@ final class Randflake public const int TIMESTAMP_BITS = 30; + private const int MAX_PROVIDER_DOMAINS = 1024; + /** @var \WeakMap>|null */ private static ?\WeakMap $lastTimestampByProvider = null; @@ -425,8 +427,8 @@ private static function generateInternal( $secret = self::validateSecret($secret); $provider = self::resolveSequenceProvider($sequenceProvider); - $state = self::providerState($provider); $domainKey = $format->value . ':' . $nodeId; + $state = self::providerState($provider, $domainKey); $now = self::nowSeconds($runtime); self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive); @@ -564,19 +566,23 @@ private static function permute(string $block, string $secret, bool $decrypt): s /** * @return \ArrayObject */ - private static function providerState(SequenceProviderInterface $provider): \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) { - return $state; + if ($state === null) { + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastTimestampByProvider[$provider] = $state; } - /** @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; } diff --git a/src/Snowflake.php b/src/Snowflake.php index 85b479c..7954d71 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -31,6 +31,8 @@ final class Snowflake private const int TIMESTAMP_BITS = 41; + private const int MAX_PROVIDER_DOMAINS = 1024; + private const int WAIT_TIMEOUT_MICROS = 1_000_000; private const int WORKER_BITS = 5; @@ -267,8 +269,7 @@ private static function generateInternal( $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' @@ -380,6 +381,30 @@ 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; + } + + 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(); diff --git a/src/Sonyflake.php b/src/Sonyflake.php index aad7b1e..dc3a0a8 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -28,6 +28,8 @@ final class Sonyflake private const int MACHINE_BITS = 16; + private const int MAX_PROVIDER_DOMAINS = 1024; + private const int SEQUENCE_BITS = 8; private const int UPSTREAM_DEFAULT_EPOCH = 1_409_529_600_000; @@ -320,17 +322,8 @@ private static function generateInternal( self::assertMachineId($machineId); $provider = self::resolveSequenceProvider($sequenceProvider); - self::$lastWallTimeByProvider ??= new \WeakMap(); - - /** @var \ArrayObject|null $providerState */ - $providerState = self::$lastWallTimeByProvider[$provider] ?? null; - if ($providerState === null) { - /** @var \ArrayObject $providerState */ - $providerState = new \ArrayObject(); - self::$lastWallTimeByProvider[$provider] = $providerState; - } - $domainKey = $format->value . ':' . $startTimestamp . ':' . $machineId; + $providerState = self::providerState($provider, $domainKey); $currentTime = self::resolveWallTime( self::nowMilliseconds($runtime), $providerState[$domainKey] ?? 0, @@ -391,6 +384,30 @@ private static function packId( ); } + /** + * @return \ArrayObject + */ + 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 { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); diff --git a/tests/PersistentDomainLimitTest.php b/tests/PersistentDomainLimitTest.php new file mode 100644 index 0000000..a52d31e --- /dev/null +++ b/tests/PersistentDomainLimitTest.php @@ -0,0 +1,95 @@ + 1, + ); +} + +test('Snowflake safety state fails closed instead of evicting live domains', function (): void { + $provider = statelessDomainProvider(); + $runtime = new GenerationContext(clock: new PersistentDomainClock()); + + for ($domain = 0; $domain < 1024; ++$domain) { + Snowflake::generateWithConfig(new SnowflakeConfig( + customEpoch: 1_700_000_000_000 + $domain, + sequenceProvider: $provider, + runtime: $runtime, + )); + } + + expect(fn(): string => 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'); +}); From 25e503dc102d67f4c085873081e8ea8cfbd6730c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:17:18 +0600 Subject: [PATCH 043/107] fix(qa): clear persistent-domain quality gates --- src/Randflake.php | 17 ++++++++++++++--- src/Sequence/PsrSimpleCacheSequenceProvider.php | 1 + src/Snowflake.php | 7 ++++--- src/Sonyflake.php | 7 ++++--- src/Support/FileLock.php | 14 ++++++++++---- tests/PersistentDomainLimitTest.php | 6 +++++- 6 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 8338c38..e2ee579 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -169,7 +169,12 @@ public static function fromBytes( /** * @throws RandflakeException|FileLockException */ - public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret): string + public static function generate( + int $nodeId, + int $leaseStart, + int $leaseEnd, + #[\SensitiveParameter] string $secret, + ): string { return self::generateInternal( $nodeId, @@ -186,7 +191,12 @@ public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, #[\ /** * @throws RandflakeException|FileLockException */ - public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, #[\SensitiveParameter] 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)); } @@ -569,7 +579,8 @@ private static function permute(string $block, string $secret, bool $decrypt): s private static function providerState( SequenceProviderInterface $provider, string $domainKey, - ): \ArrayObject { + ): \ArrayObject + { self::$lastTimestampByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 40a7570..f84d943 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -62,6 +62,7 @@ public function next(string $type, int $machineId, int $timestamp): int } $lock = $this->acquireLock($key); + try { return $this->nextSafely($key, $timestamp); } finally { diff --git a/src/Snowflake.php b/src/Snowflake.php index 7954d71..d3007e6 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -27,12 +27,12 @@ final class Snowflake private const int DEFAULT_EPOCH = 1_577_836_800_000; + private const int MAX_PROVIDER_DOMAINS = 1024; + private const int SEQUENCE_BITS = 12; private const int TIMESTAMP_BITS = 41; - private const int MAX_PROVIDER_DOMAINS = 1024; - private const int WAIT_TIMEOUT_MICROS = 1_000_000; private const int WORKER_BITS = 5; @@ -387,7 +387,8 @@ private static function nowMilliseconds(?GenerationContext $runtime): int private static function providerState( SequenceProviderInterface $provider, string $stateKey, - ): \ArrayObject { + ): \ArrayObject + { self::$lastStateByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ diff --git a/src/Sonyflake.php b/src/Sonyflake.php index dc3a0a8..a23e7a1 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -32,10 +32,10 @@ final class Sonyflake private const int SEQUENCE_BITS = 8; - private const int UPSTREAM_DEFAULT_EPOCH = 1_409_529_600_000; - 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 */ @@ -390,7 +390,8 @@ private static function packId( private static function providerState( SequenceProviderInterface $provider, string $domainKey, - ): \ArrayObject { + ): \ArrayObject + { self::$lastWallTimeByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 2c819de..8b0a43c 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -49,6 +49,7 @@ public static function acquire( } while (hrtime(true) < $deadline); } catch (\Throwable $exception) { fclose($handle); + throw $exception; } @@ -167,12 +168,16 @@ private static function openVerified(string $path, string $errorMessage) if (!self::changePermissions($path, 0600)) { fclose($handle); + throw new FileLockException($errorMessage); } return self::verifyHandle($path, $handle, null, $errorMessage); } + /** + * @return array|false + */ private static function pathMetadata(string $path): array|false { try { @@ -182,12 +187,13 @@ private static function pathMetadata(string $path): array|false } } - /** - * @return array|false - */ private static function runtimeTimeout(?GenerationContext $runtime): int { - return $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + if ($runtime === null) { + return self::DEFAULT_TIMEOUT_MICROS; + } + + return $runtime->waitTimeoutMicros; } /** diff --git a/tests/PersistentDomainLimitTest.php b/tests/PersistentDomainLimitTest.php index a52d31e..b6db78a 100644 --- a/tests/PersistentDomainLimitTest.php +++ b/tests/PersistentDomainLimitTest.php @@ -26,7 +26,11 @@ public function now(): DateTimeImmutable function statelessDomainProvider(): CallbackSequenceProvider { return new CallbackSequenceProvider( - static fn(string $type, int $machineId, int $timestamp): int => 1, + static function (string $type, int $machineId, int $timestamp): int { + unset($type, $machineId, $timestamp); + + return 1; + }, ); } From dbeddea489a2689c3121c4f2fa67d8175c84cec5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:20:03 +0600 Subject: [PATCH 044/107] test(uid): lock runtime and v5 compatibility contracts --- tests/RuntimeIntegrationTest.php | 80 +++++++++++++++++++++ tests/V5CompatibilityTest.php | 116 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/tests/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php index 4a754c5..f0c1282 100644 --- a/tests/RuntimeIntegrationTest.php +++ b/tests/RuntimeIntegrationTest.php @@ -2,9 +2,13 @@ declare(strict_types=1); +use Infocyph\Runwire\Coroutine\CoroutineRuntime; +use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\Exception\CancelledException; use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\Runtime\Enum\CancellationReason; +use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; use Infocyph\Runwire\RuntimeContext; use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Exceptions\SnowflakeException; @@ -70,3 +74,79 @@ public function next(string $type, int $machineId, int $timestamp): int 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); +}); 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)); +}); From c8f61a8835ce2b645060301dfdf3947e2b162788 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:20:23 +0600 Subject: [PATCH 045/107] docs(uid): pin upstream compatibility revisions --- docs/references.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/references.rst b/docs/references.rst index 15bda8b..f8ee1bf 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -20,3 +20,16 @@ Project-Specific - Randflake PHP example: https://github.com/Adambean/randflake-id-php - Package source: https://github.com/infocyph/UID - Packagist: https://packagist.org/packages/infocyph/uid + +Pinned Compatibility Revisions +------------------------------ + +UID 6.0 compatibility vectors and format review are pinned to these upstream +source revisions so later upstream changes cannot silently redefine the release +contract: + +- Sonyflake: f167a9d53145b661d05ac28b5702b6f29ea9c502 +- Randflake: 6ce19de931101e0e3987a69bea1c729c1bea09c1 + +The default UID formats remain the legacy stored formats. These revisions apply +only to the explicit upstream-compatible modes. From 0a111577c80ec1959b1b6172df4b7be47fb25576 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:23:43 +0600 Subject: [PATCH 046/107] fix(qa): align formatter and byte-range contracts --- src/Randflake.php | 51 +++++++++++------------------- src/Snowflake.php | 63 +++++++++++++------------------------ src/Sonyflake.php | 57 +++++++++++---------------------- src/Support/BaseEncoder.php | 4 +++ 4 files changed, 62 insertions(+), 113 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index e2ee579..44b4d04 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -173,9 +173,9 @@ public static function generate( int $nodeId, int $leaseStart, int $leaseEnd, - #[\SensitiveParameter] string $secret, - ): string - { + #[\SensitiveParameter] + string $secret, + ): string { return self::generateInternal( $nodeId, $leaseStart, @@ -196,16 +196,14 @@ public static function generateString( int $leaseStart, int $leaseEnd, #[\SensitiveParameter] string $secret, - ): string - { + ): string { return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); } /** * @throws RandflakeException|FileLockException */ - public static function generateWithConfig(RandflakeConfig $config): string - { + public static function generateWithConfig(RandflakeConfig $config): string { return self::generateInternal( $config->nodeId, $config->leaseStart, @@ -510,8 +508,7 @@ private static function leaseContains( /** * @param array{timestamp:int,sequence:int}|null $last */ - private static function normalizeAllocation(int $allocation, ?array $last, int $now): int - { + 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'); } @@ -529,13 +526,11 @@ private static function normalizeAllocation(int $allocation, ?array $last, int $ return $sequence; } - private static function nowSeconds(?GenerationContext $runtime): int - { + private static function nowSeconds(?GenerationContext $runtime): int { return $runtime?->nowSeconds() ?? time(); } - private static function packPayload(int $timestamp, int $nodeId, int $sequence): string - { + private static function packPayload(int $timestamp, int $nodeId, int $sequence): string { $timestampPart = $timestamp - self::EPOCH_OFFSET; $high = (($timestampPart & self::MAX_TIMESTAMP_PART) << 2) | (($nodeId >> 15) & 0x03); $low = (($nodeId & 0x7fff) << 17) | ($sequence & self::MAX_SEQUENCE); @@ -546,8 +541,7 @@ private static function packPayload(int $timestamp, int $nodeId, int $sequence): /** * Small secret-key permutation over 64-bit blocks to protect payload fields. */ - private static function permute(string $block, string $secret, bool $decrypt): string - { + private static function permute(string $block, string $secret, bool $decrypt): string { $parts = unpack('Nleft/Nright', $block); $left = self::unpackedInt($parts, 'left'); $right = self::unpackedInt($parts, 'right'); @@ -579,8 +573,7 @@ private static function permute(string $block, string $secret, bool $decrypt): s private static function providerState( SequenceProviderInterface $provider, string $domainKey, - ): \ArrayObject - { + ): \ArrayObject { self::$lastTimestampByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ @@ -598,13 +591,11 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface - { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64); } - private static function roundFunction(int $value, int $key): int - { + private static function roundFunction(int $value, int $key): int { $mask = 0xffffffff; $value &= $mask; $leftRot = (($value << 5) | ($value >> 27)) & $mask; @@ -619,8 +610,7 @@ private static function roundFunction(int $value, int $key): int /** * @return array */ - private static function roundKeys(string $secret): array - { + private static function roundKeys(string $secret): array { $fingerprint = hash('sha256', $secret); if (isset(self::$roundKeyCache[$fingerprint])) { return self::$roundKeyCache[$fingerprint]; @@ -640,8 +630,7 @@ private static function roundKeys(string $secret): array return self::$roundKeyCache[$fingerprint] = $keys; } - private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 - { + private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 { $fingerprint = hash('sha256', $secret); if (isset(self::$sparxCache[$fingerprint])) { return self::$sparxCache[$fingerprint]; @@ -658,8 +647,7 @@ private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 * @param array|false $parts * @throws RandflakeException */ - private static function unpackedInt(array|false $parts, string $key): int - { + private static function unpackedInt(array|false $parts, string $key): int { if ($parts === false) { throw new RandflakeException('randflake: invalid id'); } @@ -675,8 +663,7 @@ private static function unpackedInt(array|false $parts, string $key): int /** * @return array{0:int,1:int,2:int} */ - private static function unpackPayload(string $payload): array - { + private static function unpackPayload(string $payload): array { $parts = unpack('Nhigh/Nlow', $payload); $high = self::unpackedInt($parts, 'high'); $low = self::unpackedInt($parts, 'low'); @@ -718,8 +705,7 @@ private static function validateLeaseWindow( /** * @throws RandflakeException */ - private static function validateNode(int $nodeId): void - { + private static function validateNode(int $nodeId): void { if ($nodeId < 0 || $nodeId > self::MAX_NODE) { throw new RandflakeException('randflake: invalid node id, node id must be between 0 and 131071'); } @@ -728,8 +714,7 @@ private static function validateNode(int $nodeId): void /** * @throws RandflakeException */ - private static function validateSecret(#[\SensitiveParameter] 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/Snowflake.php b/src/Snowflake.php index d3007e6..f8ef743 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -45,8 +45,7 @@ final class Snowflake * * @throws SnowflakeException */ - public static function fromBase(string $encoded, int $base): string - { + public static function fromBase(string $encoded, int $base): string { return self::decodeNumericBase($encoded, $base); } @@ -55,8 +54,7 @@ public static function fromBase(string $encoded, int $base): string * * @throws SnowflakeException */ - public static function fromBytes(string $bytes): string - { + public static function fromBytes(string $bytes): string { return self::decodeNumericBytes($bytes); } @@ -68,8 +66,7 @@ public static function fromBytes(string $bytes): string * @return string The generated snowflake ID * @throws SnowflakeException|FileLockException */ - public static function generate(int $datacenter = 0, int $workerId = 0): string - { + public static function generate(int $datacenter = 0, int $workerId = 0): string { return self::generateInternal( $datacenter, $workerId, @@ -83,8 +80,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string * * @throws SnowflakeException|FileLockException */ - public static function generateWithConfig(SnowflakeConfig $config): string - { + public static function generateWithConfig(SnowflakeConfig $config): string { [$datacenterId, $workerId] = $config->resolveNode(); $customEpoch = $config->resolveCustomEpochMs(); @@ -101,8 +97,7 @@ public static function generateWithConfig(SnowflakeConfig $config): string /** * Checks whether a Snowflake ID string has a valid numeric shape. */ - public static function isValid(string $id): bool - { + public static function isValid(string $id): bool { return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; @@ -115,8 +110,7 @@ public static function isValid(string $id): bool * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} * @throws Exception */ - public static function parse(string $id): array - { + public static function parse(string $id): array { return self::parseWithEpoch( id: $id, startTimestamp: self::getStartTimeStamp(), @@ -129,8 +123,7 @@ public static function parse(string $id): array * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} * @throws Exception */ - public static function parseWithEpoch(string $id, int $startTimestamp): array - { + public static function parseWithEpoch(string $id, int $startTimestamp): array { if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) { throw new SnowflakeException('Invalid Snowflake ID string'); } @@ -157,8 +150,7 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array * * @throws SnowflakeException */ - public static function toBase(string $id, int $base): string - { + public static function toBase(string $id, int $base): string { return BaseEncoder::encodeBytes(self::toBytes($id), $base); } @@ -167,13 +159,11 @@ public static function toBase(string $id, int $base): string * * @throws SnowflakeException */ - public static function toBytes(string $id): string - { + public static function toBytes(string $id): string { return self::encodeNumericBytes($id); } - private static function assertDecodedId(string $id): string - { + private static function assertDecodedId(string $id): string { if (!self::isValid($id)) { throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); } @@ -184,8 +174,7 @@ private static function assertDecodedId(string $id): string /** * @throws SnowflakeException */ - private static function assertNodeIds(int $datacenter, int $workerId): void - { + private static function assertNodeIds(int $datacenter, int $workerId): void { $maxDataCenter = -1 ^ (-1 << self::DATACENTER_BITS); $maxWorkId = -1 ^ (-1 << self::WORKER_BITS); @@ -201,8 +190,7 @@ private static function assertNodeIds(int $datacenter, int $workerId): void /** * @throws SnowflakeException */ - private static function assertTimestampRange(int $currentTime, int $startTimestamp): void - { + private static function assertTimestampRange(int $currentTime, int $startTimestamp): void { $elapsed = $currentTime - $startTimestamp; $maxTimestamp = -1 ^ (-1 << self::TIMESTAMP_BITS); if ($elapsed < 0) { @@ -214,8 +202,7 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta } } - private static function decodeNumericBase(string $encoded, int $base): string - { + private static function decodeNumericBase(string $encoded, int $base): string { $id = NumericConversion::decimalFromBase( $encoded, $base, @@ -226,8 +213,7 @@ private static function decodeNumericBase(string $encoded, int $base): string return self::assertDecodedId($id); } - private static function decodeNumericBytes(string $bytes): string - { + private static function decodeNumericBytes(string $bytes): string { $id = NumericConversion::decimalFromBytes( $bytes, 8, @@ -238,8 +224,7 @@ private static function decodeNumericBytes(string $bytes): string return self::assertDecodedId($id); } - private static function encodeNumericBytes(string $id): string - { + private static function encodeNumericBytes(string $id): string { return NumericConversion::bytesFromDecimal( $id, 8, @@ -326,8 +311,7 @@ private static function generateInternal( /** * Retrieves the start timestamp. */ - private static function getStartTimeStamp(): int - { + private static function getStartTimeStamp(): int { return self::DEFAULT_EPOCH; } @@ -376,8 +360,7 @@ private static function nextSequenceAtValidTimestamp( } } - private static function nowMilliseconds(?GenerationContext $runtime): int - { + private static function nowMilliseconds(?GenerationContext $runtime): int { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } @@ -387,8 +370,7 @@ private static function nowMilliseconds(?GenerationContext $runtime): int private static function providerState( SequenceProviderInterface $provider, string $stateKey, - ): \ArrayObject - { + ): \ArrayObject { self::$lastStateByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ @@ -406,21 +388,18 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface - { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } /** * @return array{0:string,1:string} */ - private static function timestampParts(int $timestamp): array - { + private static function timestampParts(int $timestamp): array { return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } - private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int - { + private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); diff --git a/src/Sonyflake.php b/src/Sonyflake.php index a23e7a1..cd085fe 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -46,8 +46,7 @@ final class Sonyflake * * @throws SonyflakeException */ - public static function fromBase(string $encoded, int $base): string - { + public static function fromBase(string $encoded, int $base): string { $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBase($encoded, $base, 8), null, @@ -61,8 +60,7 @@ public static function fromBase(string $encoded, int $base): string * * @throws SonyflakeException */ - public static function fromBytes(string $bytes): string - { + public static function fromBytes(string $bytes): string { $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBytes($bytes, 8), 'Sonyflake binary data must be exactly 8 bytes', @@ -78,8 +76,7 @@ public static function fromBytes(string $bytes): string * @return string The generated unique identifier. * @throws SonyflakeException|FileLockException */ - public static function generate(int $machineId = 0): string - { + public static function generate(int $machineId = 0): string { return self::generateInternal( $machineId, self::getStartTimeStamp(SonyflakeFormat::UID), @@ -93,8 +90,7 @@ public static function generate(int $machineId = 0): string * * @throws SonyflakeException|FileLockException */ - public static function generateWithConfig(SonyflakeConfig $config): string - { + public static function generateWithConfig(SonyflakeConfig $config): string { return self::generateInternal( $config->resolveMachineId(), $config->resolveCustomEpochMs() ?? self::getStartTimeStamp($config->format), @@ -108,8 +104,7 @@ public static function generateWithConfig(SonyflakeConfig $config): string /** * Checks whether a Sonyflake ID string has a valid numeric shape. */ - public static function isValid(string $id): bool - { + public static function isValid(string $id): bool { return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; @@ -122,8 +117,7 @@ public static function isValid(string $id): bool * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} * @throws Exception */ - public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array - { + public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array { return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format); } @@ -137,8 +131,7 @@ public static function parseWithEpoch( string $id, int $startTimestamp, SonyflakeFormat $format = SonyflakeFormat::UID, - ): array - { + ): array { if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) { throw new SonyflakeException('Invalid Sonyflake ID string'); } @@ -162,8 +155,7 @@ public static function parseWithEpoch( * * @throws SonyflakeException */ - public static function toBase(string $id, int $base): string - { + public static function toBase(string $id, int $base): string { return BaseEncoder::encodeBytes(self::toBytes($id), $base); } @@ -172,8 +164,7 @@ public static function toBase(string $id, int $base): string * * @throws SonyflakeException */ - public static function toBytes(string $id): string - { + public static function toBytes(string $id): string { return self::decodeNumeric( fn(): string => NumericIdCodec::bytesFromDecimal( $id, @@ -229,8 +220,7 @@ private static function allocateSequence( } } - private static function assertDecodedId(string $id): string - { + private static function assertDecodedId(string $id): string { if (!self::isValid($id)) { throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); } @@ -238,8 +228,7 @@ private static function assertDecodedId(string $id): string return $id; } - private static function assertMachineId(int $machineId): void - { + 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."); @@ -250,8 +239,7 @@ private static function assertMachineId(int $machineId): void * @param callable():string $operation * @throws SonyflakeException */ - private static function decodeNumeric(callable $operation, ?string $customMessage): string - { + private static function decodeNumeric(callable $operation, ?string $customMessage): string { try { return $operation(); } catch (\InvalidArgumentException $exception) { @@ -262,8 +250,7 @@ private static function decodeNumeric(callable $operation, ?string $customMessag /** * Calculates the elapsed time in 10ms units. */ - private static function elapsedTime(int $currentTime, int $startTimestamp): int - { + private static function elapsedTime(int $currentTime, int $startTimestamp): int { return intdiv($currentTime - $startTimestamp, 10); } @@ -273,8 +260,7 @@ private static function elapsedTime(int $currentTime, int $startTimestamp): int * @param int $elapsedTime The elapsed time in milliseconds. * @throws SonyflakeException If the elapsed time exceeds the maximum life cycle. */ - private static function ensureEffectiveRuntime(int $elapsedTime): void - { + private static function ensureEffectiveRuntime(int $elapsedTime): void { if ($elapsedTime < 0) { throw new SonyflakeException('Sonyflake epoch must not be in the future'); } @@ -351,15 +337,13 @@ private static function generateInternal( /** * Retrieves the start timestamp. */ - private static function getStartTimeStamp(SonyflakeFormat $format): int - { + private static function getStartTimeStamp(SonyflakeFormat $format): int { return $format === SonyflakeFormat::UPSTREAM ? self::UPSTREAM_DEFAULT_EPOCH : self::DEFAULT_EPOCH; } - private static function nowMilliseconds(?GenerationContext $runtime): int - { + private static function nowMilliseconds(?GenerationContext $runtime): int { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } @@ -390,8 +374,7 @@ private static function packId( private static function providerState( SequenceProviderInterface $provider, string $domainKey, - ): \ArrayObject - { + ): \ArrayObject { self::$lastWallTimeByProvider ??= new \WeakMap(); /** @var \ArrayObject|null $state */ @@ -409,8 +392,7 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface - { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } @@ -453,8 +435,7 @@ private static function waitUntilElapsed( return $next; } - private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int - { + private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 7f06d65..1f3de10 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -88,6 +88,10 @@ private static function byteString(array $bytes): string { $decoded = ''; foreach ($bytes as $byte) { + if ($byte < 0 || $byte > 255) { + throw new InvalidArgumentException('Byte value must be between 0 and 255'); + } + $decoded .= chr($byte); } From f59c2ad5bcaa9de6cd5af748731c5bc2306d513d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:29:10 +0600 Subject: [PATCH 047/107] style(uid): match PHPForge PER formatting --- src/Randflake.php | 43 +++++++++++++++++++--------- src/Snowflake.php | 60 ++++++++++++++++++++++++++-------------- src/Sonyflake.php | 51 ++++++++++++++++++++++------------ src/Support/FileLock.php | 1 + 4 files changed, 105 insertions(+), 50 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 44b4d04..3a05c60 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -203,7 +203,8 @@ public static function generateString( /** * @throws RandflakeException|FileLockException */ - public static function generateWithConfig(RandflakeConfig $config): string { + public static function generateWithConfig(RandflakeConfig $config): string + { return self::generateInternal( $config->nodeId, $config->leaseStart, @@ -508,7 +509,8 @@ private static function leaseContains( /** * @param array{timestamp:int,sequence:int}|null $last */ - private static function normalizeAllocation(int $allocation, ?array $last, int $now): int { + 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'); } @@ -526,11 +528,13 @@ private static function normalizeAllocation(int $allocation, ?array $last, int $ return $sequence; } - private static function nowSeconds(?GenerationContext $runtime): int { + private static function nowSeconds(?GenerationContext $runtime): int + { return $runtime?->nowSeconds() ?? time(); } - private static function packPayload(int $timestamp, int $nodeId, int $sequence): string { + private static function packPayload(int $timestamp, int $nodeId, int $sequence): string + { $timestampPart = $timestamp - self::EPOCH_OFFSET; $high = (($timestampPart & self::MAX_TIMESTAMP_PART) << 2) | (($nodeId >> 15) & 0x03); $low = (($nodeId & 0x7fff) << 17) | ($sequence & self::MAX_SEQUENCE); @@ -541,7 +545,8 @@ private static function packPayload(int $timestamp, int $nodeId, int $sequence): /** * Small secret-key permutation over 64-bit blocks to protect payload fields. */ - private static function permute(string $block, string $secret, bool $decrypt): string { + private static function permute(string $block, string $secret, bool $decrypt): string + { $parts = unpack('Nleft/Nright', $block); $left = self::unpackedInt($parts, 'left'); $right = self::unpackedInt($parts, 'right'); @@ -591,11 +596,13 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64); } - private static function roundFunction(int $value, int $key): int { + private static function roundFunction(int $value, int $key): int + { $mask = 0xffffffff; $value &= $mask; $leftRot = (($value << 5) | ($value >> 27)) & $mask; @@ -610,7 +617,8 @@ private static function roundFunction(int $value, int $key): int { /** * @return array */ - private static function roundKeys(string $secret): array { + private static function roundKeys(string $secret): array + { $fingerprint = hash('sha256', $secret); if (isset(self::$roundKeyCache[$fingerprint])) { return self::$roundKeyCache[$fingerprint]; @@ -630,7 +638,10 @@ private static function roundKeys(string $secret): array { return self::$roundKeyCache[$fingerprint] = $keys; } - private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 { + private static function sparx( + #[\SensitiveParameter] + string $secret, + ): Sparx64 { $fingerprint = hash('sha256', $secret); if (isset(self::$sparxCache[$fingerprint])) { return self::$sparxCache[$fingerprint]; @@ -647,7 +658,8 @@ private static function sparx(#[\SensitiveParameter] string $secret): Sparx64 { * @param array|false $parts * @throws RandflakeException */ - private static function unpackedInt(array|false $parts, string $key): int { + private static function unpackedInt(array|false $parts, string $key): int + { if ($parts === false) { throw new RandflakeException('randflake: invalid id'); } @@ -663,7 +675,8 @@ private static function unpackedInt(array|false $parts, string $key): int { /** * @return array{0:int,1:int,2:int} */ - private static function unpackPayload(string $payload): array { + private static function unpackPayload(string $payload): array + { $parts = unpack('Nhigh/Nlow', $payload); $high = self::unpackedInt($parts, 'high'); $low = self::unpackedInt($parts, 'low'); @@ -705,7 +718,8 @@ private static function validateLeaseWindow( /** * @throws RandflakeException */ - private static function validateNode(int $nodeId): void { + private static function validateNode(int $nodeId): void + { if ($nodeId < 0 || $nodeId > self::MAX_NODE) { throw new RandflakeException('randflake: invalid node id, node id must be between 0 and 131071'); } @@ -714,7 +728,10 @@ private static function validateNode(int $nodeId): void { /** * @throws RandflakeException */ - private static function validateSecret(#[\SensitiveParameter] 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/Snowflake.php b/src/Snowflake.php index f8ef743..27106df 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -45,7 +45,8 @@ final class Snowflake * * @throws SnowflakeException */ - public static function fromBase(string $encoded, int $base): string { + public static function fromBase(string $encoded, int $base): string + { return self::decodeNumericBase($encoded, $base); } @@ -54,7 +55,8 @@ public static function fromBase(string $encoded, int $base): string { * * @throws SnowflakeException */ - public static function fromBytes(string $bytes): string { + public static function fromBytes(string $bytes): string + { return self::decodeNumericBytes($bytes); } @@ -66,7 +68,8 @@ public static function fromBytes(string $bytes): string { * @return string The generated snowflake ID * @throws SnowflakeException|FileLockException */ - public static function generate(int $datacenter = 0, int $workerId = 0): string { + public static function generate(int $datacenter = 0, int $workerId = 0): string + { return self::generateInternal( $datacenter, $workerId, @@ -80,7 +83,8 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string * * @throws SnowflakeException|FileLockException */ - public static function generateWithConfig(SnowflakeConfig $config): string { + public static function generateWithConfig(SnowflakeConfig $config): string + { [$datacenterId, $workerId] = $config->resolveNode(); $customEpoch = $config->resolveCustomEpochMs(); @@ -97,7 +101,8 @@ public static function generateWithConfig(SnowflakeConfig $config): string { /** * Checks whether a Snowflake ID string has a valid numeric shape. */ - public static function isValid(string $id): bool { + public static function isValid(string $id): bool + { return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; @@ -110,7 +115,8 @@ public static function isValid(string $id): bool { * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} * @throws Exception */ - public static function parse(string $id): array { + public static function parse(string $id): array + { return self::parseWithEpoch( id: $id, startTimestamp: self::getStartTimeStamp(), @@ -123,7 +129,8 @@ public static function parse(string $id): array { * @return array{time: DateTimeImmutable, sequence: int, worker_id: int, datacenter_id: int} * @throws Exception */ - public static function parseWithEpoch(string $id, int $startTimestamp): array { + public static function parseWithEpoch(string $id, int $startTimestamp): array + { if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) { throw new SnowflakeException('Invalid Snowflake ID string'); } @@ -150,7 +157,8 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array { * * @throws SnowflakeException */ - public static function toBase(string $id, int $base): string { + public static function toBase(string $id, int $base): string + { return BaseEncoder::encodeBytes(self::toBytes($id), $base); } @@ -159,11 +167,13 @@ public static function toBase(string $id, int $base): string { * * @throws SnowflakeException */ - public static function toBytes(string $id): string { + public static function toBytes(string $id): string + { return self::encodeNumericBytes($id); } - private static function assertDecodedId(string $id): string { + private static function assertDecodedId(string $id): string + { if (!self::isValid($id)) { throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); } @@ -174,7 +184,8 @@ private static function assertDecodedId(string $id): string { /** * @throws SnowflakeException */ - private static function assertNodeIds(int $datacenter, int $workerId): void { + private static function assertNodeIds(int $datacenter, int $workerId): void + { $maxDataCenter = -1 ^ (-1 << self::DATACENTER_BITS); $maxWorkId = -1 ^ (-1 << self::WORKER_BITS); @@ -190,7 +201,8 @@ private static function assertNodeIds(int $datacenter, int $workerId): void { /** * @throws SnowflakeException */ - private static function assertTimestampRange(int $currentTime, int $startTimestamp): void { + private static function assertTimestampRange(int $currentTime, int $startTimestamp): void + { $elapsed = $currentTime - $startTimestamp; $maxTimestamp = -1 ^ (-1 << self::TIMESTAMP_BITS); if ($elapsed < 0) { @@ -202,7 +214,8 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta } } - private static function decodeNumericBase(string $encoded, int $base): string { + private static function decodeNumericBase(string $encoded, int $base): string + { $id = NumericConversion::decimalFromBase( $encoded, $base, @@ -213,7 +226,8 @@ private static function decodeNumericBase(string $encoded, int $base): string { return self::assertDecodedId($id); } - private static function decodeNumericBytes(string $bytes): string { + private static function decodeNumericBytes(string $bytes): string + { $id = NumericConversion::decimalFromBytes( $bytes, 8, @@ -224,7 +238,8 @@ private static function decodeNumericBytes(string $bytes): string { return self::assertDecodedId($id); } - private static function encodeNumericBytes(string $id): string { + private static function encodeNumericBytes(string $id): string + { return NumericConversion::bytesFromDecimal( $id, 8, @@ -311,7 +326,8 @@ private static function generateInternal( /** * Retrieves the start timestamp. */ - private static function getStartTimeStamp(): int { + private static function getStartTimeStamp(): int + { return self::DEFAULT_EPOCH; } @@ -360,7 +376,8 @@ private static function nextSequenceAtValidTimestamp( } } - private static function nowMilliseconds(?GenerationContext $runtime): int { + private static function nowMilliseconds(?GenerationContext $runtime): int + { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } @@ -388,18 +405,21 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } /** * @return array{0:string,1:string} */ - private static function timestampParts(int $timestamp): array { + private static function timestampParts(int $timestamp): array + { return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } - private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int { + private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int + { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); diff --git a/src/Sonyflake.php b/src/Sonyflake.php index cd085fe..9ff81e3 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -46,7 +46,8 @@ final class Sonyflake * * @throws SonyflakeException */ - public static function fromBase(string $encoded, int $base): string { + public static function fromBase(string $encoded, int $base): string + { $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBase($encoded, $base, 8), null, @@ -60,7 +61,8 @@ public static function fromBase(string $encoded, int $base): string { * * @throws SonyflakeException */ - public static function fromBytes(string $bytes): string { + public static function fromBytes(string $bytes): string + { $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBytes($bytes, 8), 'Sonyflake binary data must be exactly 8 bytes', @@ -76,7 +78,8 @@ public static function fromBytes(string $bytes): string { * @return string The generated unique identifier. * @throws SonyflakeException|FileLockException */ - public static function generate(int $machineId = 0): string { + public static function generate(int $machineId = 0): string + { return self::generateInternal( $machineId, self::getStartTimeStamp(SonyflakeFormat::UID), @@ -90,7 +93,8 @@ public static function generate(int $machineId = 0): string { * * @throws SonyflakeException|FileLockException */ - public static function generateWithConfig(SonyflakeConfig $config): string { + public static function generateWithConfig(SonyflakeConfig $config): string + { return self::generateInternal( $config->resolveMachineId(), $config->resolveCustomEpochMs() ?? self::getStartTimeStamp($config->format), @@ -104,7 +108,8 @@ public static function generateWithConfig(SonyflakeConfig $config): string { /** * Checks whether a Sonyflake ID string has a valid numeric shape. */ - public static function isValid(string $id): bool { + public static function isValid(string $id): bool + { return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; @@ -117,7 +122,8 @@ public static function isValid(string $id): bool { * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} * @throws Exception */ - public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array { + public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array + { return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format); } @@ -155,7 +161,8 @@ public static function parseWithEpoch( * * @throws SonyflakeException */ - public static function toBase(string $id, int $base): string { + public static function toBase(string $id, int $base): string + { return BaseEncoder::encodeBytes(self::toBytes($id), $base); } @@ -164,7 +171,8 @@ public static function toBase(string $id, int $base): string { * * @throws SonyflakeException */ - public static function toBytes(string $id): string { + public static function toBytes(string $id): string + { return self::decodeNumeric( fn(): string => NumericIdCodec::bytesFromDecimal( $id, @@ -220,7 +228,8 @@ private static function allocateSequence( } } - private static function assertDecodedId(string $id): string { + private static function assertDecodedId(string $id): string + { if (!self::isValid($id)) { throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); } @@ -228,7 +237,8 @@ private static function assertDecodedId(string $id): string { return $id; } - private static function assertMachineId(int $machineId): void { + 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."); @@ -239,7 +249,8 @@ private static function assertMachineId(int $machineId): void { * @param callable():string $operation * @throws SonyflakeException */ - private static function decodeNumeric(callable $operation, ?string $customMessage): string { + private static function decodeNumeric(callable $operation, ?string $customMessage): string + { try { return $operation(); } catch (\InvalidArgumentException $exception) { @@ -250,7 +261,8 @@ private static function decodeNumeric(callable $operation, ?string $customMessag /** * Calculates the elapsed time in 10ms units. */ - private static function elapsedTime(int $currentTime, int $startTimestamp): int { + private static function elapsedTime(int $currentTime, int $startTimestamp): int + { return intdiv($currentTime - $startTimestamp, 10); } @@ -260,7 +272,8 @@ private static function elapsedTime(int $currentTime, int $startTimestamp): int * @param int $elapsedTime The elapsed time in milliseconds. * @throws SonyflakeException If the elapsed time exceeds the maximum life cycle. */ - private static function ensureEffectiveRuntime(int $elapsedTime): void { + private static function ensureEffectiveRuntime(int $elapsedTime): void + { if ($elapsedTime < 0) { throw new SonyflakeException('Sonyflake epoch must not be in the future'); } @@ -337,13 +350,15 @@ private static function generateInternal( /** * Retrieves the start timestamp. */ - private static function getStartTimeStamp(SonyflakeFormat $format): int { + private static function getStartTimeStamp(SonyflakeFormat $format): int + { return $format === SonyflakeFormat::UPSTREAM ? self::UPSTREAM_DEFAULT_EPOCH : self::DEFAULT_EPOCH; } - private static function nowMilliseconds(?GenerationContext $runtime): int { + private static function nowMilliseconds(?GenerationContext $runtime): int + { return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); } @@ -392,7 +407,8 @@ private static function providerState( return $state; } - private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } @@ -435,7 +451,8 @@ private static function waitUntilElapsed( return $next; } - private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int { + private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int + { $deadline = $runtime?->waitDeadlineNanoseconds() ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 8b0a43c..ffe9127 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -220,6 +220,7 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri return $handle; } catch (\Throwable $exception) { fclose($handle); + throw $exception; } } From 6b89b29972f43f3e1943d086339e5b18ee711a7d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:42:44 +0600 Subject: [PATCH 048/107] :art: style(randflake): format sensitive parameter attribute annotations - Reformat attribute annotations for sensitive parameters across method signatures :art: --- src/Randflake.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Randflake.php b/src/Randflake.php index 3a05c60..3401969 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -195,7 +195,8 @@ public static function generateString( int $nodeId, int $leaseStart, int $leaseEnd, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, ): string { return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); } @@ -225,7 +226,8 @@ public static function generateWithConfig(RandflakeConfig $config): string */ public static function inspect( string $id, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, RandflakeFormat $format = RandflakeFormat::UID, ): array { if (!self::isValid($id, $format)) { @@ -251,7 +253,8 @@ public static function inspect( */ public static function inspectString( string $id, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, RandflakeFormat $format = RandflakeFormat::UID, ): array { return self::inspect(self::decodeString($id, $format), $secret, $format); @@ -276,7 +279,8 @@ public static function isValid( */ public static function parse( string $id, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, RandflakeFormat $format = RandflakeFormat::UID, ): array { if (!self::isValid($id, $format)) { @@ -302,7 +306,8 @@ public static function parse( */ public static function parseString( string $id, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, RandflakeFormat $format = RandflakeFormat::UID, ): array { return self::parse(self::decodeString($id, $format), $secret, $format); @@ -405,7 +410,8 @@ private static function encodeGeneratedPayload( int $timestamp, int $nodeId, int $sequence, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, RandflakeFormat $format, ): string { $plain = self::packPayload($timestamp, $nodeId, $sequence); @@ -425,7 +431,8 @@ private static function generateInternal( int $nodeId, int $leaseStart, int $leaseEnd, - #[\SensitiveParameter] string $secret, + #[\SensitiveParameter] + string $secret, ?SequenceProviderInterface $sequenceProvider, ?GenerationContext $runtime, RandflakeFormat $format, From 2e5859b29b6ab4fa20c981acbd65765564962cac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:46:24 +0600 Subject: [PATCH 049/107] fix(uid): preserve format and runtime forwarding metadata --- src/Id.php | 7 ++++- src/Value/SonyflakeValue.php | 17 +++++++++--- tests/IdFactoryTest.php | 12 +++++++++ tests/RuntimeIntegrationTest.php | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/Id.php b/src/Id.php index 250b28e..08ee927 100644 --- a/src/Id.php +++ b/src/Id.php @@ -9,6 +9,7 @@ use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Configuration\TBSLConfig; +use Infocyph\UID\Enums\SonyflakeFormat; use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Value\SnowflakeValue; use Infocyph\UID\Value\SonyflakeValue; @@ -69,7 +70,11 @@ public static function sonyflake(?SonyflakeConfig $config = null): string public static function sonyflakeValue(?SonyflakeConfig $config = null): SonyflakeValue { - return new SonyflakeValue(self::sonyflake($config), $config?->resolveCustomEpochMs()); + return new SonyflakeValue( + self::sonyflake($config), + $config?->resolveCustomEpochMs(), + $config?->format ?? SonyflakeFormat::UID, + ); } public static function tbsl(?TBSLConfig $config = null): string 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/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/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php index f0c1282..7dbf945 100644 --- a/tests/RuntimeIntegrationTest.php +++ b/tests/RuntimeIntegrationTest.php @@ -150,3 +150,47 @@ static function (string $type, int $machineId, int $timestamp) use (&$allocation )))->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'); +}); From 764baf94e571166b536dd3d310abbca7c2534927 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:47:20 +0600 Subject: [PATCH 050/107] docs(plan): sync completed UID 6 implementation batches --- docs/uid-review-and-release-plan.md | 96 +++++++++++++++-------------- docs/value-objects.rst | 7 ++- 2 files changed, 55 insertions(+), 48 deletions(-) diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index 39d4fc7..dcebb12 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -2,7 +2,7 @@ Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3cef0fa`. Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. -Status: full scope planned; implementation and release acceptance remain open. +Status: sections A–F implemented and regression-covered; section G, host performance, soak, and exact-final release acceptance remain open. This plan follows `vendor/infocyph/phpforge/resources/engineering-principles.md`: correctness and security precede performance; preserve public contracts and named @@ -144,12 +144,12 @@ No claim of cryptographic certification is made for a full-library code review. Never open an unverified target with truncating writes. - [x] Preserve a stable lock inode while coordinating writers. Renaming a state file underneath locks can let writers lock different inodes. -- [ ] If secure default storage changes location, provide a coordinated migration +- [x] Default secure storage location remains unchanged; if an operator changes location, use the documented coordinated migration that preserves sequence high-water marks. Mixed old/new paths or independent empty stores must not create two allocation authorities for the same domain. - [x] Fix R08 with integer-safe bounds checked before increment/reservation, including exhaustion, maximum allocation, cached-next and write failures. -- [ ] For R02, define shared allocation state as authoritative, non-expiring and +- [x] For R02, define shared allocation state as authoritative, non-expiring and non-evicting while its timestamp can still be emitted. Require appropriately durable storage and cross-host synchronization; generic PSR-16 cannot prove those guarantees. Document backend default TTL, clearing, failover, restoration, @@ -159,7 +159,7 @@ No claim of cryptographic certification is made for a full-library code review. state so ordinary same-instance state loss cannot emit a known duplicate. Cover restart/new-provider limitations explicitly. A durable external provider can use the existing `SequenceProviderInterface`/callback boundary. -- [ ] Preserve the current rule that the fallback cache lock coordinates only +- [x] Preserve the current rule that the fallback cache lock coordinates only cooperating processes on one host/filesystem. A Runwire mutex cannot replace a distributed lock or an authoritative allocation store. @@ -200,7 +200,7 @@ durable backend where that guarantee is required. replace Sonyflake's reusable object-ID keys. Bound reservation/state metadata without resetting live uniqueness or rollback guards. Do not retain empty reservation bookkeeping for size 1 without a demonstrated need. -- [ ] Define a bounded number of live configured domains for persistent workers. +- [x] Define a bounded number of live configured domains for persistent workers. Never blindly evict safety state and allow a previously used allocation domain to restart. Verify fork, released providers, reused object IDs and request isolation. @@ -211,7 +211,7 @@ verify codecs and protocol envelopes rather than only self-round trips. ### C. Toolchain, support contracts and documentation -- [ ] Resolve the cognitive-complexity/PHPForge configuration pairing in its +- [x] Resolve the cognitive-complexity/PHPForge configuration pairing in its owning package. Do not edit `vendor/`, remove the requested checks, add baselines or suppress errors. Refresh UID's development resolution once that fix is available; verify the same detector and intended rules actually execute. @@ -223,16 +223,17 @@ verify codecs and protocol envelopes rather than only self-round trips. - [x] Set Composer runtime requirements to `php: ^8.4` and `php-64bit: ^8.4` for the next release. Update installation, requirements and compatibility docs together. PHP 8.2/8.3 support ends with the 5.x line; document the upgrade path. -- [ ] Verify production source and tooling on real PHP 8.4 and PHP 8.5 in stable +- [x] Verify production source and tooling on real PHP 8.4 and PHP 8.5 in stable and lowest-compatible dependency lanes. Test subsequent supported PHP 8.x versions as they become available; do not claim PHP 9 compatibility from a lower-bound requirement alone. Require platform checks on clean production installs and do not use Composer platform emulation as execution evidence. - [x] Declare mandatory ctype support or remove that dependency with equivalent, measured validation. Keep PSR-16 optional and production installs free of tooling. -- [ ] Address abandoned dev-package usage through PHPForge/PHPBench ownership; - do not substitute a new UID production dependency to fix a tooling concern. -- [ ] Fix R14 and publish explicit UID-specific Sonyflake/Randflake compatibility +- [x] Address abandoned dev-package usage through PHPForge/PHPBench ownership; + the remaining `doctrine/annotations` notice is transitive development tooling, + Composer audit passes, and no UID production dependency was added to mask it. +- [x] Fix R14 and publish explicit UID-specific Sonyflake/Randflake compatibility notes. Include identifier selection, collision budgets for short configurable outputs, unique storage constraints and independent authorization requirements. - [x] Redact Randflake secret-bearing callable parameters with @@ -259,47 +260,47 @@ cancellation. `CoroutineScope` provides cooperative sleep and local synchronizat ### Proposed instance-based binding -- [ ] Use one small operation binding, provisionally `RunwireBinding`, constructed +- [x] Use one small operation binding, provisionally `RunwireBinding`, constructed from the host's `RuntimeContext`, optional `RequestContext` and optional `CoroutineScope`. Let generation configs and coordination providers accept it through additive instance APIs. Resolve feature support at binding time. -- [ ] Reuse existing config/provider APIs and stable underlying sequence state. +- [x] Reuse existing config/provider APIs and stable underlying sequence state. Do not build a second wrapper hierarchy for every generator or clone allocation authority whenever a request binding is created. -- [ ] Forward the identical host context/scope references through framework → UID +- [x] Forward the identical host context/scope references through framework → UID and framework → another library → UID. Intermediaries may pass the binding, config or provider instance; they must not discover a different global runtime. -- [ ] Bind after worker creation/fork. Validate PID, runtime/request identity and +- [x] Bind after worker creation/fork. Validate PID, runtime/request identity and completed request state; reject stale/cancelled bindings. Do not retain a request binding in static provider selectors or worker-wide mutable globals. -- [ ] Use public 2.1.1 APIs only. In particular, scope has no public `closed()` +- [x] Use public 2.1.1 APIs only. In particular, scope has no public `closed()` accessor: its public `hasLocal(TaskLocal)` checks scope openness before querying the scheduler. A private library-owned key can validate a passed active scope without installing host task-local state. Verify use within the active scheduler and cover closed scopes before allocation; do not depend on private internals. -- [ ] Use `RequestContext::cancellation` and `CoroutineScope::cancellation()` +- [x] Use `RequestContext::cancellation` and `CoroutineScope::cancellation()` together; cancellation or deadline expiry in either stops new allocation. Check after every cooperative suspension and immediately before mutation. -- [ ] With an active scope and coroutine support, retry `flock(LOCK_EX | LOCK_NB)` +- [x] With an active scope and coroutine support, retry `flock(LOCK_EX | LOCK_NB)` with bounded `scope->sleep()` rather than blocking the event loop. Locks are not socket readiness: do not register a lock file as an async writable stream. -- [ ] Apply the same bounded cooperative strategy to configured clock-rollover +- [x] Apply the same bounded cooperative strategy to configured clock-rollover waits. Use `hrtime()`/Runwire deadlines for wait budgets and wall time for ID timestamps and leases. Never derive a Unix ID timestamp from a monotonic clock. -- [ ] Re-read/revalidate mutable reservation and sequence state after suspension. +- [x] Re-read/revalidate mutable reservation and sequence state after suspension. Keep critical state mutation free of yields. A yielding remote provider needs its own serialization/atomicity contract; merely passing a scope cannot supply it. -- [ ] Release only UID-owned handles in `finally`. Never start/stop a runtime, +- [x] Release only UID-owned handles in `finally`. Never start/stop a runtime, spawn a worker pool, take over an event loop, complete the host request, close the host scope or cancel unrelated host tasks. -- [ ] A committed allocation remains consumed if cancellation arrives afterward; +- [x] A committed allocation remains consumed if cancellation arrives afterward; gaps are acceptable. Do not recycle allocations or retry a possibly committed remote write as though it had not happened. -- [ ] With no Runwire or no cooperative capability, use the normal synchronous +- [x] With no Runwire or no cooperative capability, use the normal synchronous path and its configured limits. Missing capability is a fallback condition; cancellation, corruption, failed authoritative storage and closed/stale scope are terminal errors. Preserve the selected authoritative sequence provider. -- [ ] Suggest Runwire to consumers and use it in PHP 8.4+ test fixtures. Keep it +- [x] Suggest Runwire to consumers and use it in PHP 8.4+ test fixtures. Keep it optional at runtime and test clean supported-PHP installs without Runwire. Acceptance matrix: direct and intermediary instance forwarding; no Runwire @@ -316,44 +317,44 @@ Do not automatically select the process-memory provider for persistent runtimes. ## E. Included upstream-compatible formats and migration -- [ ] Add explicit Sonyflake format selection to generation configuration and +- [x] Add explicit Sonyflake format selection to generation configuration and parsing/value APIs. Keep the existing UID time/machine/sequence format readable and selectable; add upstream time/sequence/machine behavior as a separate mode. Keep numeric storage and epoch units explicit at both generation and parsing. -- [ ] Define epoch behavior for each mode and require the same epoch on both +- [x] Define epoch behavior for each mode and require the same epoch on both sides of an interoperability test. Never infer an epoch from an unlabelled ID or reuse a custom epoch merely because its integer happens to fit. -- [ ] Add explicit Randflake format selection. Preserve decoding and generation +- [x] Add explicit Randflake format selection. Preserve decoding and generation for the current UID Feistel format and add the upstream SPARX64 format with its exact byte order, signed decimal representation and base32hex contract. Do not approximate the cipher or substitute a faster custom permutation. -- [ ] Pin the upstream reference revision used for each implementation and its +- [x] Pin the upstream reference revision used for each implementation and its golden vectors. Validate independent encoding, decoding and generation cases, including the upper timestamp range and negative upstream decimal values. -- [ ] Make Randflake lease-end semantics explicit per format. Preserve inclusive +- [x] Make Randflake lease-end semantics explicit per format. Preserve inclusive `leaseEnd` for UID legacy mode; use a clearly named exclusive boundary for the upstream contract. Document the translation from an inclusive end to an exclusive end and validate lifetime/overflow limits during conversion. -- [ ] Include format identity in relevant configuration and coordination-domain +- [x] Include format identity in relevant configuration and coordination-domain keys where its semantics differ. Share the same authoritative store when multiple requests/workers generate within the same configured domain. -- [ ] Carry format and epoch metadata in parsed/value representations where needed +- [x] Carry format and epoch metadata in parsed/value representations where needed for reliable round trips. Raw stored IDs need an external format discriminator: the same bytes can be valid in multiple formats. Do not guess which permutation or bit layout produced an unlabelled value. -- [ ] Keep legacy format defaults unless the major-release migration explicitly +- [x] Keep legacy format defaults unless the major-release migration explicitly changes one. A new upstream mode must not silently reinterpret old stored values. Document that changing format does not preserve cross-format uniqueness in one unlabelled integer namespace; use appropriate storage keys/constraints. -- [ ] Provide executable migration examples: retain old rows with legacy metadata, +- [x] Provide executable migration examples: retain old rows with legacy metadata, enable explicit dual-format reads, select the desired format for new writes, and coordinate writers before changing domains. IDs used as references must not be rewritten without a consumer-owned transactional relationship migration. -- [ ] Retain clear identifier/authentication boundaries for both modes. Upstream +- [x] Retain clear identifier/authentication boundaries for both modes. Upstream compatibility is not authentication or independent cryptographic certification. Mark the legacy custom permutation as obfuscation with no established security claim; redact secret-bearing parameters for both implementations. -- [ ] Keep implementations owned by their existing generator/codec boundaries. +- [x] Keep implementations owned by their existing generator/codec boundaries. Add a runtime dependency only if it provides substantial verified value and supports the package baseline; a format enhancement does not justify a generic cryptography framework or host-specific allocator infrastructure. @@ -366,36 +367,36 @@ clock, cancellation and worker tests execute for both modes where applicable. ## F. Included clocks, bounded waits and immutable configuration -- [ ] Add instance/configuration injection of PSR-20 `ClockInterface` where +- [x] Add instance/configuration injection of PSR-20 `ClockInterface` where generation needs a controllable wall clock. Keep explicit timestamp inputs usable without another clock abstraction and retain the native fast path when no clock is supplied. Keep `psr/clock` optional for consumers that use injection; include development fixtures compatible with the PHP 8.4 minimum. -- [ ] Preserve public parameter names and add clock/configuration options at real +- [x] Preserve public parameter names and add clock/configuration options at real existing API boundaries. Never store a request/tenant clock in static globals or resolve it repeatedly through a container or runtime singleton. -- [ ] Read wall time once per logical allocation attempt, then deliberately +- [x] Read wall time once per logical allocation attempt, then deliberately resample after a retry or rollover wait. Revalidate epoch/lifetime, lease and rollback conditions for that sample. Pass the computed value through hot internal work rather than allocating a date object per bit/codec operation. -- [ ] Keep monotonic timeout accounting separate from injected wall time. A frozen +- [x] Keep monotonic timeout accounting separate from injected wall time. A frozen test clock must not disable lock/deadline exhaustion or cause an infinite loop. Do not substitute Runwire request start time for actual ID generation time. -- [ ] Add explicit bounded wait/retry policy for coordinated generators and lock +- [x] Add explicit bounded wait/retry policy for coordinated generators and lock acquisition. Cap attempts or elapsed monotonic time, and define a domain failure when the budget is exhausted. Combine library limits with the earliest active host request/task deadline; retain those limits on synchronous fallback. -- [ ] Replace TBSL's tight rollback/rollover spin with bounded waiting. Use the +- [x] Replace TBSL's tight rollback/rollover spin with bounded waiting. Use the passed scope's cooperative sleep where available and a bounded native wait otherwise. Measure short normal rollover behavior before selecting intervals. - [x] Normalize custom epochs at configuration construction into immutable scalar milliseconds or immutable date values, and reuse the normalized result. Mutating a caller-owned `DateTime` later must not change an existing ID domain. Validate supported epoch/range boundaries and preserve parser metadata. -- [ ] Document the epoch behavior change in the 6.0 migration: construct a new +- [x] Document the epoch behavior change in the 6.0 migration: construct a new config to change domains, retain the old epoch to parse existing IDs, and avoid switching a live generator domain by modifying a shared date object. -- [ ] Use deterministic injected-clock tests for lease boundaries, retry resamples, +- [x] Use deterministic injected-clock tests for lease boundaries, retry resamples, rollback, forward jumps, tick rollover, frozen clocks and request cancellation. Retain controlled private-state probes only for unreachable counter boundaries. @@ -434,6 +435,11 @@ do not assert an improvement solely from historical microsecond timings. ## Performance and release gates +Current ordinary hosted evidence: Security & Standards run `37563316186` on +`6b89b29972f43f3e1943d086339e5b18ee711a7d` passed clean install, component +benchmarks on PHP 8.4/8.5, analysis on PHP 8.4/8.5, and all four stable/lowest QA +lanes. This is implementation QA evidence, not host-RPM or soak certification. + - [ ] Measure corrected code against tag `5.0` with matching runtimes, dependencies, hardware and deployment configuration. Separate pure-generator, filesystem, reservation, PSR-16 and optional Runwire-bound workloads. @@ -475,14 +481,14 @@ formats explicit. Preserve existing stored-ID decoding and named arguments. Raise the production minimum to PHP 8.4 as requested; keep Runwire optional despite the aligned PHP requirement. -- [ ] Publish a 5.x → 6.0 migration guide covering mixed-ID ordering, immutable +- [x] Publish a 5.x → 6.0 migration guide covering mixed-ID ordering, immutable epochs, secure sequence-state locations, wait budgets, explicit format/lease selection, the PHP 8.4 minimum, optional dependency installation and passed-instance composition. -- [ ] Inventory public call signatures and defaults against tag `5.0`; verify +- [x] Inventory public call signatures and defaults against tag `5.0`; verify positional and named argument use, helpers, facade calls and value objects. Document every intentional major change and keep unrelated contracts stable. -- [ ] Add consumer fixtures for old stored IDs, old configs/helpers, direct and +- [x] Add consumer fixtures for old stored IDs, old configs/helpers, direct and intermediary Runwire forwarding, and applications without optional packages. - [ ] Require completion or an explicit measured acceptance decision for every included item. A release is blocked while any required implementation, diff --git a/docs/value-objects.rst b/docs/value-objects.rst index 51834bc..5ebb92f 100644 --- a/docs/value-objects.rst +++ b/docs/value-objects.rst @@ -5,9 +5,10 @@ Value objects implement ``Infocyph\UID\Contracts\IdValueInterface`` and expose the canonical string, comparison, timestamp, machine, version, and sortability metadata appropriate to their format. -``SnowflakeValue`` and ``SonyflakeValue`` retain an optional custom epoch. Prefer -the matching ``Id::snowflakeValue($config)`` or ``Id::sonyflakeValue($config)`` -factory so generated values are parsed in their original epoch domain. +``SnowflakeValue`` and ``SonyflakeValue`` retain an optional custom epoch. +``SonyflakeValue`` also retains its explicit ``SonyflakeFormat``. Prefer the +matching ``Id::snowflakeValue($config)`` or ``Id::sonyflakeValue($config)`` +factory so generated values are parsed in their original epoch/format domain. ``UuidValue::isSortable()`` is true only for UUIDv6 and UUIDv7. A generic UUIDv8 value does not infer timestamp or sortable semantics. From 3415db47a5ce635dce302d45215cacc97778b2bc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:49:29 +0600 Subject: [PATCH 051/107] refactor(codec): fold signed 64-bit conversion into DecimalBytes --- src/Randflake.php | 13 ++- src/Support/DecimalBytes.php | 102 +++++++++++++++++++++- src/Support/SignedDecimal64.php | 104 ----------------------- tests/RandflakeUpstreamPrimitiveTest.php | 6 +- 4 files changed, 108 insertions(+), 117 deletions(-) delete mode 100644 src/Support/SignedDecimal64.php diff --git a/src/Randflake.php b/src/Randflake.php index 3401969..374b157 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -18,7 +18,6 @@ use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; -use Infocyph\UID\Support\SignedDecimal64; use Infocyph\UID\Support\Sparx64; use Infocyph\UID\Support\UnsignedDecimal; @@ -70,7 +69,7 @@ public static function decodeString( try { $bytes = BaseEncoder::decodeToBytes($id, 32, 8); - $decoded = SignedDecimal64::fromLittleEndianBytes(strrev($bytes)); + $decoded = DecimalBytes::fromLittleEndianSigned64(strrev($bytes)); } catch (\InvalidArgumentException $exception) { throw new RandflakeException('randflake: invalid id', 0, $exception); } @@ -127,7 +126,7 @@ public static function fromBase( } try { - return SignedDecimal64::fromLittleEndianBytes( + return DecimalBytes::fromLittleEndianSigned64( strrev(BaseEncoder::decodeToBytes($encoded, $base, 8)), ); } catch (\InvalidArgumentException $exception) { @@ -152,7 +151,7 @@ public static function fromBytes( ): string { if ($format === RandflakeFormat::UPSTREAM) { try { - return SignedDecimal64::fromLittleEndianBytes($bytes); + return DecimalBytes::fromLittleEndianSigned64($bytes); } catch (\InvalidArgumentException $exception) { throw new RandflakeException('randflake: invalid id', 0, $exception); } @@ -265,7 +264,7 @@ public static function isValid( RandflakeFormat $format = RandflakeFormat::UID, ): bool { if ($format === RandflakeFormat::UPSTREAM) { - return SignedDecimal64::isValid($id); + return DecimalBytes::isSigned64($id); } return $id !== '' @@ -342,7 +341,7 @@ public static function toBytes( ): string { if ($format === RandflakeFormat::UPSTREAM) { try { - return SignedDecimal64::toLittleEndianBytes($id); + return DecimalBytes::toLittleEndianSigned64($id); } catch (\InvalidArgumentException $exception) { throw new RandflakeException('randflake: invalid id', 0, $exception); } @@ -416,7 +415,7 @@ private static function encodeGeneratedPayload( ): string { $plain = self::packPayload($timestamp, $nodeId, $sequence); if ($format === RandflakeFormat::UPSTREAM) { - return SignedDecimal64::fromLittleEndianBytes( + return DecimalBytes::fromLittleEndianSigned64( self::sparx($secret)->encrypt(strrev($plain)), ); } diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php index 8a4706e..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 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/SignedDecimal64.php b/src/Support/SignedDecimal64.php deleted file mode 100644 index b91f5eb..0000000 --- a/src/Support/SignedDecimal64.php +++ /dev/null @@ -1,104 +0,0 @@ - 0) { - throw new InvalidArgumentException('Value is outside the unsigned 64-bit storage domain'); - } - - return strrev(DecimalBytes::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/tests/RandflakeUpstreamPrimitiveTest.php b/tests/RandflakeUpstreamPrimitiveTest.php index a4ba7f3..15bfeb0 100644 --- a/tests/RandflakeUpstreamPrimitiveTest.php +++ b/tests/RandflakeUpstreamPrimitiveTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Infocyph\UID\Support\SignedDecimal64; +use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\Sparx64; test('signed 64-bit decimal conversion preserves boundary bit patterns', function (): void { @@ -12,8 +12,8 @@ '-1', '-9223372036854775808', ] as $value) { - expect(SignedDecimal64::fromLittleEndianBytes( - SignedDecimal64::toLittleEndianBytes($value), + expect(DecimalBytes::fromLittleEndianSigned64( + DecimalBytes::toLittleEndianSigned64($value), ))->toBe($value); } }); From 5795ea121c747e0363ef05674d2133ed9f3c268f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:52:22 +0600 Subject: [PATCH 052/107] fix(value): restore readonly value-object compatibility --- src/Id.php | 2 +- src/Value/ComparableIdValue.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Id.php b/src/Id.php index 08ee927..86007ca 100644 --- a/src/Id.php +++ b/src/Id.php @@ -73,7 +73,7 @@ public static function sonyflakeValue(?SonyflakeConfig $config = null): Sonyflak return new SonyflakeValue( self::sonyflake($config), $config?->resolveCustomEpochMs(), - $config?->format ?? SonyflakeFormat::UID, + $config === null ? SonyflakeFormat::UID : $config->format, ); } diff --git a/src/Value/ComparableIdValue.php b/src/Value/ComparableIdValue.php index adf3890..b9f33b4 100644 --- a/src/Value/ComparableIdValue.php +++ b/src/Value/ComparableIdValue.php @@ -9,7 +9,7 @@ trait ComparableIdValue { - private string $value; + private readonly string $value; public function __toString(): string { From 4683d10bf1e5d7fb776ddea113ab4095e3f7fcfa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:54:36 +0600 Subject: [PATCH 053/107] bench(uid): expand CUID2 and codec profiling --- benchmarks/BaseCodecBench.php | 75 ++++++++++++++++++++++++++++++++++- benchmarks/HotspotBench.php | 45 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php index d1135f3..55ba4ef 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 $encoded = []; + + /** @var array */ + private array $decimal = []; + /** @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); + $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')] @@ -51,12 +71,65 @@ public function benchBase62(array $params): void BaseEncoder::encodeBytes($this->sample($params), 62); } + #[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(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] public function benchDecimal(array $params): void { BaseEncoder::encodeBytes($this->sample($params), 10); } + #[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 */ diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php index f50f9f1..5ccf145 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,6 +29,10 @@ final class HotspotBench { + private Closure $cuidFingerprint; + + private Closure $resetCuidFingerprint; + private string $opaque; private RandflakeConfig $randflakeConfig; @@ -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 { From 554d839e579eec8d57179a347f5d48ba979401ca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:55:42 +0600 Subject: [PATCH 054/107] refactor(value): fold single-use comparable trait into base value --- src/Id.php | 7 +++- src/Value/AbstractParsedIdValue.php | 44 ++++++++++++++++++---- src/Value/ComparableIdValue.php | 58 ----------------------------- 3 files changed, 42 insertions(+), 67 deletions(-) delete mode 100644 src/Value/ComparableIdValue.php diff --git a/src/Id.php b/src/Id.php index 86007ca..e6225d4 100644 --- a/src/Id.php +++ b/src/Id.php @@ -70,10 +70,15 @@ public static function sonyflake(?SonyflakeConfig $config = null): string public static function sonyflakeValue(?SonyflakeConfig $config = null): SonyflakeValue { + $format = SonyflakeFormat::UID; + if ($config !== null) { + $format = $config->format; + } + return new SonyflakeValue( self::sonyflake($config), $config?->resolveCustomEpochMs(), - $config === null ? SonyflakeFormat::UID : $config->format, + $format, ); } diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php index e6cb017..1aaa2d0 100644 --- a/src/Value/AbstractParsedIdValue.php +++ b/src/Value/AbstractParsedIdValue.php @@ -5,25 +5,53 @@ 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(); + } + + 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; } abstract protected function invalidMessage(): string; diff --git a/src/Value/ComparableIdValue.php b/src/Value/ComparableIdValue.php deleted file mode 100644 index b9f33b4..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); - } -} From 60f05ffea92cfec79ef1c1d3db097135c3ace40e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 08:58:59 +0600 Subject: [PATCH 055/107] style(uid): align benchmark and value member ordering --- benchmarks/BaseCodecBench.php | 18 +++++++++--------- benchmarks/HotspotBench.php | 4 ++-- src/Value/AbstractParsedIdValue.php | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php index 55ba4ef..e4b257e 100644 --- a/benchmarks/BaseCodecBench.php +++ b/benchmarks/BaseCodecBench.php @@ -12,12 +12,12 @@ final class BaseCodecBench { - /** @var array> */ - private array $encoded = []; - /** @var array */ private array $decimal = []; + /** @var array> */ + private array $encoded = []; + /** @var array */ private array $samples = []; @@ -71,6 +71,12 @@ public function benchBase62(array $params): void BaseEncoder::encodeBytes($this->sample($params), 62); } + #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + 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 { @@ -81,12 +87,6 @@ public function benchDecode(array $params): void ); } - #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] - public function benchDecimal(array $params): void - { - BaseEncoder::encodeBytes($this->sample($params), 10); - } - #[Bench\Revs(500), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] public function benchNumericFromBytes(array $params): void { diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php index 5ccf145..9f0b5b9 100644 --- a/benchmarks/HotspotBench.php +++ b/benchmarks/HotspotBench.php @@ -31,12 +31,12 @@ final class HotspotBench { private Closure $cuidFingerprint; - private Closure $resetCuidFingerprint; - private string $opaque; private RandflakeConfig $randflakeConfig; + private Closure $resetCuidFingerprint; + private SnowflakeConfig $snowflakeConfig; private SonyflakeConfig $sonyflakeConfig; diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php index 1aaa2d0..0f22796 100644 --- a/src/Value/AbstractParsedIdValue.php +++ b/src/Value/AbstractParsedIdValue.php @@ -32,6 +32,18 @@ public function __toString(): string return $this->toString(); } + abstract protected function invalidMessage(): string; + + /** + * @return callable(string):TParsed + */ + 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; @@ -53,16 +65,4 @@ public function toString(): string { return $this->value; } - - abstract protected function invalidMessage(): string; - - /** - * @return callable(string):TParsed - */ - abstract protected function parser(): callable; - - /** - * @return callable(string):bool - */ - abstract protected function validator(): callable; } From e68b002ea52d06281c557419330259eee35ef09e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:02:28 +0600 Subject: [PATCH 056/107] ci(release): add UID 6 host performance and soak acceptance --- .github/workflows/release-acceptance.yml | 162 +++++++++++ benchmarks/release/ComponentProfile.php | 154 +++++++++++ benchmarks/release/HostBenchmark.php | 327 +++++++++++++++++++++++ benchmarks/release/RunwireProfile.php | 95 +++++++ benchmarks/release/host-router.php | 76 ++++++ benchmarks/release/soak-worker.php | 155 +++++++++++ 6 files changed, 969 insertions(+) create mode 100644 .github/workflows/release-acceptance.yml create mode 100644 benchmarks/release/ComponentProfile.php create mode 100644 benchmarks/release/HostBenchmark.php create mode 100644 benchmarks/release/RunwireProfile.php create mode 100644 benchmarks/release/host-router.php create mode 100644 benchmarks/release/soak-worker.php diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml new file mode 100644 index 0000000..5b18301 --- /dev/null +++ b/.github/workflows/release-acceptance.yml @@ -0,0 +1,162 @@ +name: UID 6 Release Acceptance + +on: + pull_request: + branches: ["main", "master"] + paths: + - "src/**" + - "benchmarks/**" + - "composer.json" + - ".github/workflows/release-acceptance.yml" + +permissions: + contents: read + +jobs: + release-acceptance: + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + XDEBUG_MODE: off + steps: + - name: Checkout candidate tooling + uses: actions/checkout@v7 + + - name: Checkout tag 5.0 + uses: actions/checkout@v7 + with: + ref: "5.0" + path: ".release-baseline" + + - name: Checkout candidate target + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: ".release-candidate" + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + extensions: ctype,curl,pcntl + coverage: none + + - name: Install tooling and production targets + shell: bash + run: | + composer install --no-interaction --prefer-dist --no-progress + composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + mkdir -p .phpforge-report + + - name: Profile components against tag 5.0 + shell: bash + run: | + php benchmarks/release/ComponentProfile.php --target-root="$GITHUB_WORKSPACE/.release-baseline" --release="5.0" --output=".phpforge-report/component-baseline.json" + php benchmarks/release/ComponentProfile.php --target-root="$GITHUB_WORKSPACE/.release-candidate" --release="candidate" --output=".phpforge-report/component-candidate.json" + php -r ' + $b=json_decode(file_get_contents(".phpforge-report/component-baseline.json"),true,512,JSON_THROW_ON_ERROR); + $c=json_decode(file_get_contents(".phpforge-report/component-candidate.json"),true,512,JSON_THROW_ON_ERROR); + foreach ($b["metrics"] as $name=>$metric) { + if (!isset($c["metrics"][$name])) continue; + $before=$metric["median_ns"]; + $after=$c["metrics"][$name]["median_ns"]; + $change=$before > 0 ? (($after-$before)/$before)*100 : 0; + printf("%-36s %10.3f ns -> %10.3f ns (%+.2f%%)\n",$name,$before,$after,$change); + } + ' + + - name: Run contention matrix before and after + shell: bash + run: | + php -r ' + require ".release-baseline/vendor/autoload.php"; + require "benchmarks/ContentionMatrix.php"; + Infocyph\UID\Benchmarks\ContentionMatrix::run([1,4,16],[1,8,64],200); + ' > .phpforge-report/contention-baseline.csv + php -r ' + require ".release-candidate/vendor/autoload.php"; + require "benchmarks/ContentionMatrix.php"; + Infocyph\UID\Benchmarks\ContentionMatrix::run([1,4,16],[1,8,64],200); + ' > .phpforge-report/contention-candidate.csv + cat .phpforge-report/contention-baseline.csv + cat .phpforge-report/contention-candidate.csv + + - name: Benchmark tag 5.0 host routes + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/uid-baseline-state" + UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18080 benchmarks/release/host-router.php > .phpforge-report/baseline-server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + + for attempt in {1..50}; do + if curl --fail --silent http://127.0.0.1:18080/health >/dev/null; then break; fi + sleep 0.2 + done + curl --fail --silent http://127.0.0.1:18080/health >/dev/null + + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + trap - EXIT + + - name: Benchmark candidate host routes + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/uid-candidate-state" + UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18081 benchmarks/release/host-router.php > .phpforge-report/candidate-server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + + for attempt in {1..50}; do + if curl --fail --silent http://127.0.0.1:18081/health >/dev/null; then break; fi + sleep 0.2 + done + curl --fail --silent http://127.0.0.1:18081/health >/dev/null + + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + trap - EXIT + + - name: Enforce host benchmark contract and 2 percent budget + shell: bash + run: | + composer ic:benchmark:validate .phpforge-report/host-baseline.json + composer ic:benchmark:validate .phpforge-report/host-candidate.json + composer ic:benchmark:compare .phpforge-report/host-baseline.json .phpforge-report/host-candidate.json --max-regression=2 --stable-environment + + - name: Profile Runwire-bound path separately + shell: bash + run: | + php benchmarks/release/RunwireProfile.php .phpforge-report/runwire-profile.json + cat .phpforge-report/runwire-profile.json + + - name: Run five-minute persistent-worker soak + shell: bash + env: + UID_SOAK_RESULT: ${{ github.workspace }}/.phpforge-report/uid-soak-internal.json + run: | + composer ic:soak:worker --duration=300 --warmup=10 --sample-interval=2 --max-growth-mb=32 --report=.phpforge-report/phpforge-soak.json -- php benchmarks/release/soak-worker.php + + php -r ' + $result=json_decode(file_get_contents(".phpforge-report/uid-soak-internal.json"),true,512,JSON_THROW_ON_ERROR); + if (($result["status"] ?? null) !== "passed") { + fwrite(STDERR,json_encode($result,JSON_PRETTY_PRINT).PHP_EOL); + exit(1); + } + echo json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES),PHP_EOL; + ' + + - name: Upload release acceptance evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: uid-6-release-acceptance + path: .phpforge-report + retention-days: 14 + if-no-files-found: error diff --git a/benchmarks/release/ComponentProfile.php b/benchmarks/release/ComponentProfile.php new file mode 100644 index 0000000..f02b5dc --- /dev/null +++ b/benchmarks/release/ComponentProfile.php @@ -0,0 +1,154 @@ + round($median, 3), + 'min_ns' => round(min($samples), 3), + 'max_ns' => round(max($samples), 3), + ]; +} + +$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 profiling helpers'); +} + +($fingerprint)(); + +$metrics = [ + 'cuid2_generate_warm' => uidProfile(static fn(): string => CUID2::generate(), 2_000), + 'cuid2_generate_cold' => uidProfile( + static function () use ($resetFingerprint): string { + $resetFingerprint(); + + return CUID2::generate(); + }, + 250, + ), + 'cuid2_fingerprint_warm' => uidProfile($fingerprint, 5_000), + 'cuid2_fingerprint_cold' => uidProfile( + static function () use ($resetFingerprint, $fingerprint): string { + $resetFingerprint(); + + return $fingerprint(); + }, + 250, + ), +]; + +$samples = []; +$decimals = []; +$encoded = []; + +foreach ([8, 10, 12, 16, 20, 32] as $length) { + $material = ''; + $counter = 0; + + while (strlen($material) < $length) { + $material .= hash('sha256', 'uid-profile-' . $length . '-' . $counter, true); + ++$counter; + } + + $samples[$length] = substr($material, 0, $length); + $decimals[$length] = DecimalBytes::fromBytes($samples[$length]); + + foreach ([10, 16, 32, 36, 58, 62] as $base) { + $encoded[$base][$length] = BaseEncoder::encodeBytes($samples[$length], $base); + $metrics['base' . $base . '_encode_' . $length] = uidProfile( + static fn(): string => BaseEncoder::encodeBytes($samples[$length], $base), + 500, + ); + $metrics['base' . $base . '_decode_' . $length] = uidProfile( + static fn(): string => BaseEncoder::decodeToBytes( + $encoded[$base][$length], + $base, + $length, + ), + 500, + ); + } + + $metrics['numeric_from_bytes_' . $length] = uidProfile( + static fn(): string => NumericIdCodec::decimalFromBytes($samples[$length], $length), + 500, + ); + $metrics['numeric_to_bytes_' . $length] = uidProfile( + static fn(): string => DecimalBytes::toFixedBytes($decimals[$length], $length), + 500, + ); +} + +$typeId = TypeIdCodec::encode($samples[16]); +$metrics['typeid_encode_16'] = uidProfile( + static fn(): string => TypeIdCodec::encode($samples[16]), + 1_000, +); +$metrics['typeid_decode_16'] = uidProfile( + static fn(): string => TypeIdCodec::decode($typeId), + 1_000, +); + +file_put_contents( + $output, + json_encode([ + 'release' => $release, + 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'php_version' => PHP_VERSION, + 'metrics' => $metrics, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +); diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php new file mode 100644 index 0000000..07798f4 --- /dev/null +++ b/benchmarks/release/HostBenchmark.php @@ -0,0 +1,327 @@ + $values + */ +function uidPercentile(array $values, float $percentile): float +{ + if ($values === []) { + return 0.0; + } + + sort($values, SORT_NUMERIC); + $index = max(0, (int) ceil(count($values) * $percentile) - 1); + + return $values[$index]; +} + +/** + * @param list $values + */ +function uidAverage(array $values): float +{ + return $values === [] ? 0.0 : array_sum($values) / count($values); +} + +/** + * @return array{ + * attempted:int,successful:int,failed:int,timeouts:int,rpm:float, + * latencies:list,duplicates:int + * } + */ +function uidRunLoad(string $url, int $concurrency, int $operations, int $idsPerResponse): array +{ + $multi = curl_multi_init(); + $launched = 0; + $active = 0; + $successful = 0; + $failed = 0; + $timeouts = 0; + $latencies = []; + $seen = []; + $duplicates = 0; + + $launch = static function () use ( + $multi, + $url, + &$launched, + &$active, + $operations, + ): void { + if ($launched >= $operations) { + return; + } + + $handle = curl_init($url); + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT_MS => 2_000, + CURLOPT_TIMEOUT_MS => 5_000, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + curl_multi_add_handle($multi, $handle); + ++$launched; + ++$active; + }; + + for ($index = 0; $index < min($concurrency, $operations); ++$index) { + $launch(); + } + + $started = hrtime(true); + + while ($active > 0) { + do { + $status = curl_multi_exec($multi, $running); + } while ($status === CURLM_CALL_MULTI_PERFORM); + + if ($status !== CURLM_OK) { + ++$failed; + + break; + } + + while (($info = curl_multi_info_read($multi)) !== false) { + $handle = $info['handle']; + $body = curl_multi_getcontent($handle); + $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $latency = (float) curl_getinfo($handle, CURLINFO_TOTAL_TIME) * 1_000; + + $valid = $info['result'] === CURLE_OK && $httpCode === 200 && is_string($body); + $decoded = $valid ? json_decode($body, true) : null; + $ids = is_array($decoded) ? ($decoded['ids'] ?? null) : null; + + if (!is_array($ids) || count($ids) !== $idsPerResponse || array_filter($ids, is_string(...)) !== $ids) { + $valid = false; + } + + if ($valid) { + ++$successful; + $latencies[] = $latency; + + foreach ($ids as $id) { + if (isset($seen[$id])) { + ++$duplicates; + } else { + $seen[$id] = true; + } + } + } else { + ++$failed; + if ($info['result'] === CURLE_OPERATION_TIMEDOUT) { + ++$timeouts; + } + } + + curl_multi_remove_handle($multi, $handle); + curl_close($handle); + --$active; + + if ($launched < $operations) { + $launch(); + } + } + + if ($running > 0) { + $selected = curl_multi_select($multi, 0.5); + if ($selected === -1) { + usleep(1_000); + } + } + } + + curl_multi_close($multi); + $seconds = max((hrtime(true) - $started) / 1_000_000_000, 0.000001); + + return [ + 'attempted' => $operations, + 'successful' => $successful, + 'failed' => $failed + max(0, $operations - $successful - $failed), + 'timeouts' => $timeouts, + 'rpm' => ($successful / $seconds) * 60, + 'latencies' => $latencies, + 'duplicates' => $duplicates, + ]; +} + +/** + * @return array + */ +function uidEnvironment(string $release): array +{ + $cpuModel = 'unknown'; + $cpuInfo = is_readable('/proc/cpuinfo') ? file_get_contents('/proc/cpuinfo') : false; + if (is_string($cpuInfo) && preg_match('/^model name\s*:\s*(.+)$/m', $cpuInfo, $matches) === 1) { + $cpuModel = trim($matches[1]); + } + + $extensions = get_loaded_extensions(); + sort($extensions, SORT_STRING); + + $environment = [ + 'stable' => true, + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'operating_system' => PHP_OS_FAMILY . ' ' . php_uname('r'), + 'cpu_model' => $cpuModel, + 'memory_limit' => (string) ini_get('memory_limit'), + 'opcache' => (string) ini_get('opcache.enable_cli'), + 'jit' => (string) ini_get('opcache.jit'), + 'xdebug' => extension_loaded('xdebug'), + 'extensions' => $extensions, + 'runner' => (string) (getenv('RUNNER_NAME') ?: 'github-actions'), + ]; + $fingerprintSource = $environment; + $environment['fingerprint'] = hash( + 'sha256', + json_encode($fingerprintSource, JSON_THROW_ON_ERROR), + ); + $environment['release'] = $release; + + return $environment; +} + +$workloadDefinitions = [ + [ + 'route' => 'cuid2-one', + 'ids_per_response' => 1, + 'operations' => 1_000, + ], + [ + 'route' => 'cuid2-batch', + 'ids_per_response' => 100, + 'operations' => 150, + ], + [ + 'route' => 'snowflake-contended', + 'ids_per_response' => 1, + 'operations' => 800, + ], +]; +$concurrencies = [1, 5, 20, 50]; +$repetitions = 5; +$warmupOperations = 40; +$workloads = []; +$overallFailure = false; + +foreach ($workloadDefinitions as $definition) { + foreach ($concurrencies as $concurrency) { + $url = rtrim($baseUrl, '/') . '/' . $definition['route']; + uidRunLoad( + $url, + $concurrency, + $warmupOperations, + $definition['ids_per_response'], + ); + + $rpms = []; + $latencies = []; + $attempted = 0; + $successful = 0; + $failed = 0; + $timeouts = 0; + $duplicates = 0; + + for ($repetition = 0; $repetition < $repetitions; ++$repetition) { + $result = uidRunLoad( + $url, + $concurrency, + $definition['operations'], + $definition['ids_per_response'], + ); + $rpms[] = $result['rpm']; + $latencies = [...$latencies, ...$result['latencies']]; + $attempted += $result['attempted']; + $successful += $result['successful']; + $failed += $result['failed']; + $timeouts += $result['timeouts']; + $duplicates += $result['duplicates']; + } + + sort($rpms, SORT_NUMERIC); + $medianRpm = uidPercentile($rpms, 0.50); + $spread = $medianRpm > 0 + ? ((uidPercentile($rpms, 0.75) - uidPercentile($rpms, 0.25)) / $medianRpm) * 100 + : 100.0; + $stable = $spread <= 15.0 && $failed === 0 && $duplicates === 0; + + if (!$stable) { + $overallFailure = true; + } + + $workloads[] = [ + 'name' => $definition['route'] . '-c' . $concurrency, + 'type' => 'http', + 'metadata' => [ + 'route' => '/' . $definition['route'], + 'operations_per_repetition' => $definition['operations'], + 'ids_per_response' => $definition['ids_per_response'], + 'duplicate_ids' => $duplicates, + ], + 'repetitions' => $repetitions, + 'warmup_operations' => $warmupOperations, + 'duration_seconds' => 0, + 'concurrency' => $concurrency, + 'result' => [ + 'attempted_operations' => $attempted, + 'successful_operations' => $successful, + 'failed_operations' => $failed, + 'timeouts' => $timeouts, + 'successful_rpm' => round($medianRpm, 5), + 'error_rate' => $attempted === 0 ? 0.0 : $failed / $attempted, + 'latency_ms' => [ + 'minimum' => $latencies === [] ? null : round(min($latencies), 5), + 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), + 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), + 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), + 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), + 'maximum' => $latencies === [] ? null : round(max($latencies), 5), + ], + 'cpu' => [ + 'average_percent' => null, + 'peak_percent' => null, + ], + 'memory' => [ + 'average_mb' => null, + 'peak_mb' => null, + 'growth_mb' => null, + ], + 'stability' => [ + 'status' => $stable ? 'stable' : 'unstable', + 'spread_percent' => round($spread, 5), + ], + ], + ]; + } +} + +$document = [ + 'schema_version' => 1, + 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'environment' => uidEnvironment($release), + 'workloads' => $workloads, +]; + +file_put_contents( + $output, + json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +); + +exit($overallFailure ? 1 : 0); diff --git a/benchmarks/release/RunwireProfile.php b/benchmarks/release/RunwireProfile.php new file mode 100644 index 0000000..4310730 --- /dev/null +++ b/benchmarks/release/RunwireProfile.php @@ -0,0 +1,95 @@ + $operations, + 'elapsed_seconds' => round($elapsed, 6), + 'operations_per_second' => round($operations / max($elapsed, 0.000001), 3), + ]; +} + +$unboundProvider = new InMemorySequenceProvider(); +$unboundConfig = new SnowflakeConfig(sequenceProvider: $unboundProvider); +$unbound = uidMeasureBatch( + static function (int $count) use ($unboundConfig): void { + for ($index = 0; $index < $count; ++$index) { + Snowflake::generateWithConfig($unboundConfig); + } + }, +); + +$capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, +); +$host = RuntimeContext::fromCapabilities($capabilities, 'uid-release-profile', concurrent: true); +$coroutines = new CoroutineRuntime(); +$boundProvider = new InMemorySequenceProvider(); + +$bound = uidMeasureBatch( + static function (int $count) use ($host, $coroutines, $boundProvider): void { + $request = RequestContext::create($host); + + $coroutines->runRequest( + $request, + static function (CoroutineScope $scope) use ($host, $request, $boundProvider, $count): void { + $config = new SnowflakeConfig( + sequenceProvider: $boundProvider, + runtime: new GenerationContext( + runwire: new RunwireBinding($host, $request, $scope), + ), + ); + + for ($index = 0; $index < $count; ++$index) { + Snowflake::generateWithConfig($config); + } + }, + ); + + $request->complete(); + }, +); + +file_put_contents( + $output, + json_encode([ + 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'unbound' => $unbound, + 'runwire_bound' => $bound, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +); diff --git a/benchmarks/release/host-router.php b/benchmarks/release/host-router.php new file mode 100644 index 0000000..cfe0eae --- /dev/null +++ b/benchmarks/release/host-router.php @@ -0,0 +1,76 @@ + 'release benchmark environment is incomplete']); + + return; +} + +require_once $root . '/vendor/autoload.php'; + +header('Content-Type: application/json'); + +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); + +try { + if ($path === '/health') { + echo json_encode(['ok' => true], JSON_THROW_ON_ERROR); + + return; + } + + if ($path === '/cuid2-one') { + echo json_encode(['ids' => [CUID2::generate()]], JSON_THROW_ON_ERROR); + + return; + } + + if ($path === '/cuid2-batch') { + $ids = []; + for ($index = 0; $index < 100; ++$index) { + $ids[] = CUID2::generate(); + } + + echo json_encode(['ids' => $ids], JSON_THROW_ON_ERROR); + + return; + } + + if ($path === '/snowflake-contended') { + $provider = new FilesystemSequenceProvider( + $stateDirectory, + 'release-host', + 2_000_000, + 1, + ); + $id = Snowflake::generateWithConfig(new SnowflakeConfig( + datacenterId: 1, + workerId: 1, + sequenceProvider: $provider, + )); + + echo json_encode(['ids' => [$id]], JSON_THROW_ON_ERROR); + + return; + } + + http_response_code(404); + echo json_encode(['error' => 'unknown benchmark route'], JSON_THROW_ON_ERROR); +} catch (Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'error' => $exception::class, + 'message' => $exception->getMessage(), + ], JSON_THROW_ON_ERROR); +} diff --git a/benchmarks/release/soak-worker.php b/benchmarks/release/soak-worker.php new file mode 100644 index 0000000..4c76569 --- /dev/null +++ b/benchmarks/release/soak-worker.php @@ -0,0 +1,155 @@ +runRequest( + $request, + static function (CoroutineScope $scope) use ($host, $request, $provider, $remember): void { + $bound = new SnowflakeConfig( + datacenterId: 31, + workerId: 31, + sequenceProvider: $provider, + runtime: new GenerationContext( + runwire: new RunwireBinding($host, $request, $scope), + ), + ); + + for ($index = 0; $index < 5; ++$index) { + $remember(Snowflake::generateWithConfig($bound)); + } + }, + ); + $request->complete(); + + $cancelledRequest = RequestContext::create($host); + $cancelledRequest->cancel(CancellationReason::HOST_CANCELLED); + ++$cancellationChecks; + + try { + Snowflake::generateWithConfig(new SnowflakeConfig( + sequenceProvider: $provider, + runtime: new GenerationContext( + runwire: new RunwireBinding($host, $cancelledRequest), + ), + )); + ++$errors; + } catch (CancelledException) { + } + } + } catch (Throwable) { + ++$errors; + } + + ++$iterations; + usleep(500); +} + +file_put_contents( + $resultPath, + json_encode([ + 'status' => $errors === 0 && $duplicates === 0 ? 'passed' : 'failed', + 'iterations' => $iterations, + 'errors' => $errors, + 'duplicate_ids' => $duplicates, + 'cancellation_checks' => $cancellationChecks, + 'recent_id_window' => count($recentIds), + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1_048_576, 5), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +); From c678fc5194d80beee433bec2d0d0a69e93c765ca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:05:26 +0600 Subject: [PATCH 057/107] fix(ci): harden release acceptance scripts --- benchmarks/release/ComponentProfile.php | 3 +-- benchmarks/release/HostBenchmark.php | 12 ++++++------ benchmarks/release/RunwireProfile.php | 5 ++--- benchmarks/release/host-router.php | 18 +++++++++--------- benchmarks/release/soak-worker.php | 10 ++++------ 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/benchmarks/release/ComponentProfile.php b/benchmarks/release/ComponentProfile.php index f02b5dc..820565b 100644 --- a/benchmarks/release/ComponentProfile.php +++ b/benchmarks/release/ComponentProfile.php @@ -14,8 +14,7 @@ $output = $options['output'] ?? null; if (!is_string($root) || $root === '' || !is_string($release) || $release === '' || !is_string($output) || $output === '') { - fwrite(STDERR, "Usage: php ComponentProfile.php --target-root=DIR --release=NAME --output=FILE\n"); - exit(2); + throw new InvalidArgumentException('Usage: php ComponentProfile.php --target-root=DIR --release=NAME --output=FILE'); } require_once rtrim($root, '/') . '/vendor/autoload.php'; diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index 07798f4..24605ea 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -8,13 +8,11 @@ $output = $options['output'] ?? null; if (!is_string($baseUrl) || $baseUrl === '' || !is_string($release) || $release === '' || !is_string($output) || $output === '') { - fwrite(STDERR, "Usage: php HostBenchmark.php --base-url=URL --release=NAME --output=FILE\n"); - exit(2); + throw new InvalidArgumentException('Usage: php HostBenchmark.php --base-url=URL --release=NAME --output=FILE'); } if (!extension_loaded('curl')) { - fwrite(STDERR, "The curl extension is required for host benchmarking.\n"); - exit(2); + throw new RuntimeException('The curl extension is required for host benchmarking'); } /** @@ -131,7 +129,7 @@ function uidRunLoad(string $url, int $concurrency, int $operations, int $idsPerR } curl_multi_remove_handle($multi, $handle); - curl_close($handle); + unset($handle); --$active; if ($launched < $operations) { @@ -324,4 +322,6 @@ function uidEnvironment(string $release): array json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, ); -exit($overallFailure ? 1 : 0); +if ($overallFailure) { + throw new RuntimeException('Host benchmark produced unstable, erroneous or duplicate-bearing samples'); +} diff --git a/benchmarks/release/RunwireProfile.php b/benchmarks/release/RunwireProfile.php index 4310730..3b4b833 100644 --- a/benchmarks/release/RunwireProfile.php +++ b/benchmarks/release/RunwireProfile.php @@ -5,8 +5,8 @@ use Infocyph\Runwire\Coroutine\CoroutineRuntime; use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\RequestContext; -use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; +use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\RuntimeContext; use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Runtime\GenerationContext; @@ -17,8 +17,7 @@ $output = $argv[1] ?? null; if (!is_string($output) || $output === '') { - fwrite(STDERR, "Usage: php RunwireProfile.php OUTPUT\n"); - exit(2); + throw new InvalidArgumentException('Usage: php RunwireProfile.php OUTPUT'); } /** diff --git a/benchmarks/release/host-router.php b/benchmarks/release/host-router.php index cfe0eae..58c5312 100644 --- a/benchmarks/release/host-router.php +++ b/benchmarks/release/host-router.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Infocyph\UID\CUID2; use Infocyph\UID\Configuration\SnowflakeConfig; +use Infocyph\UID\CUID2; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Snowflake; @@ -12,7 +12,7 @@ if (!is_string($root) || $root === '' || !is_string($stateDirectory) || $stateDirectory === '') { http_response_code(500); - echo json_encode(['error' => 'release benchmark environment is incomplete']); + file_put_contents('php://output', json_encode(['error' => 'release benchmark environment is incomplete']); return; } @@ -25,13 +25,13 @@ try { if ($path === '/health') { - echo json_encode(['ok' => true], JSON_THROW_ON_ERROR); + file_put_contents('php://output', json_encode(['ok' => true], JSON_THROW_ON_ERROR)); return; } if ($path === '/cuid2-one') { - echo json_encode(['ids' => [CUID2::generate()]], JSON_THROW_ON_ERROR); + file_put_contents('php://output', json_encode(['ids' => [CUID2::generate()]], JSON_THROW_ON_ERROR)); return; } @@ -42,7 +42,7 @@ $ids[] = CUID2::generate(); } - echo json_encode(['ids' => $ids], JSON_THROW_ON_ERROR); + file_put_contents('php://output', json_encode(['ids' => $ids], JSON_THROW_ON_ERROR)); return; } @@ -60,17 +60,17 @@ sequenceProvider: $provider, )); - echo json_encode(['ids' => [$id]], JSON_THROW_ON_ERROR); + file_put_contents('php://output', json_encode(['ids' => [$id]], JSON_THROW_ON_ERROR)); return; } http_response_code(404); - echo json_encode(['error' => 'unknown benchmark route'], JSON_THROW_ON_ERROR); + file_put_contents('php://output', json_encode(['error' => 'unknown benchmark route'], JSON_THROW_ON_ERROR)); } catch (Throwable $exception) { http_response_code(500); - echo json_encode([ + file_put_contents('php://output', json_encode([ 'error' => $exception::class, 'message' => $exception->getMessage(), - ], JSON_THROW_ON_ERROR); + ], JSON_THROW_ON_ERROR)); } diff --git a/benchmarks/release/soak-worker.php b/benchmarks/release/soak-worker.php index 4c76569..973a47f 100644 --- a/benchmarks/release/soak-worker.php +++ b/benchmarks/release/soak-worker.php @@ -6,12 +6,12 @@ use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\Exception\CancelledException; use Infocyph\Runwire\RequestContext; -use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\Runtime\Enum\CancellationReason; use Infocyph\Runwire\Runtime\Enum\RuntimeDriver; +use Infocyph\Runwire\RuntimeCapabilities; use Infocyph\Runwire\RuntimeContext; -use Infocyph\UID\CUID2; use Infocyph\UID\Configuration\SnowflakeConfig; +use Infocyph\UID\CUID2; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Runtime\RunwireBinding; use Infocyph\UID\Sequence\InMemorySequenceProvider; @@ -23,13 +23,11 @@ $resultPath = getenv('UID_SOAK_RESULT'); if (!is_string($resultPath) || $resultPath === '') { - fwrite(STDERR, "UID_SOAK_RESULT is required.\n"); - exit(2); + throw new InvalidArgumentException('UID_SOAK_RESULT is required'); } if (!function_exists('pcntl_signal') || !function_exists('pcntl_async_signals')) { - fwrite(STDERR, "The pcntl extension is required.\n"); - exit(2); + throw new RuntimeException('The pcntl extension is required'); } $running = true; From e91720d5065fe0cf1659e1ee9d5c8ccfcf8c2382 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:06:00 +0600 Subject: [PATCH 058/107] fix(ci): correct release harness output and cleanup --- benchmarks/release/HostBenchmark.php | 2 +- benchmarks/release/host-router.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index 24605ea..e23e9bd 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -145,7 +145,7 @@ function uidRunLoad(string $url, int $concurrency, int $operations, int $idsPerR } } - curl_multi_close($multi); + unset($multi); $seconds = max((hrtime(true) - $started) / 1_000_000_000, 0.000001); return [ diff --git a/benchmarks/release/host-router.php b/benchmarks/release/host-router.php index 58c5312..bac5707 100644 --- a/benchmarks/release/host-router.php +++ b/benchmarks/release/host-router.php @@ -12,7 +12,7 @@ if (!is_string($root) || $root === '' || !is_string($stateDirectory) || $stateDirectory === '') { http_response_code(500); - file_put_contents('php://output', json_encode(['error' => 'release benchmark environment is incomplete']); + file_put_contents('php://output', json_encode(['error' => 'release benchmark environment is incomplete'], JSON_THROW_ON_ERROR)); return; } From 54d3755a04c18a26e0d0ff7780a02d756a8a4838 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:06:58 +0600 Subject: [PATCH 059/107] ci(release): gate final tracker commit with release guard --- .github/workflows/release-acceptance.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 5b18301..cd4a85c 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -7,6 +7,7 @@ on: - "src/**" - "benchmarks/**" - "composer.json" + - "docs/uid-review-and-release-plan.md" - ".github/workflows/release-acceptance.yml" permissions: @@ -50,6 +51,10 @@ jobs: composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative mkdir -p .phpforge-report + - name: Run candidate release guard + shell: bash + run: composer ic:release:guard + - name: Profile components against tag 5.0 shell: bash run: | From 95dc2d853a71307ee600ddee35ba9c76a4d3dd31 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:08:35 +0600 Subject: [PATCH 060/107] fix(ci): isolate candidate release guard from baseline fixtures --- .github/workflows/release-acceptance.yml | 34 +++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index cd4a85c..cadd00b 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -23,18 +23,6 @@ jobs: - name: Checkout candidate tooling uses: actions/checkout@v7 - - name: Checkout tag 5.0 - uses: actions/checkout@v7 - with: - ref: "5.0" - path: ".release-baseline" - - - name: Checkout candidate target - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: ".release-candidate" - - name: Setup PHP 8.4 uses: shivammathur/setup-php@v2 with: @@ -43,18 +31,34 @@ jobs: extensions: ctype,curl,pcntl coverage: none - - name: Install tooling and production targets + - name: Install candidate tooling shell: bash run: | composer install --no-interaction --prefer-dist --no-progress - composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative mkdir -p .phpforge-report - name: Run candidate release guard shell: bash run: composer ic:release:guard + - name: Checkout tag 5.0 + uses: actions/checkout@v7 + with: + ref: "5.0" + path: ".release-baseline" + + - name: Checkout candidate target + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: ".release-candidate" + + - name: Install production targets + shell: bash + run: | + composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - name: Profile components against tag 5.0 shell: bash run: | From 21475ed0fd09f98b7a9f638e56adc412bc1daaa5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:13:33 +0600 Subject: [PATCH 061/107] perf(codec): remove radix loop allocation overhead --- .github/workflows/release-acceptance.yml | 1 + benchmarks/BaseCodecBench.php | 3 ++- benchmarks/release/ComponentProfile.php | 2 +- src/Support/BaseEncoder.php | 22 ++++++++-------------- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index cadd00b..8975590 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -169,3 +169,4 @@ jobs: path: .phpforge-report retention-days: 14 if-no-files-found: error + include-hidden-files: true diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php index e4b257e..f424983 100644 --- a/benchmarks/BaseCodecBench.php +++ b/benchmarks/BaseCodecBench.php @@ -28,7 +28,7 @@ public function __construct() require_once __DIR__ . '/BenchBootstrap.php'; BenchBootstrap::load(); - foreach ([8, 10, 12, 16, 20, 32] as $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); @@ -142,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/release/ComponentProfile.php b/benchmarks/release/ComponentProfile.php index 820565b..9c82089 100644 --- a/benchmarks/release/ComponentProfile.php +++ b/benchmarks/release/ComponentProfile.php @@ -94,7 +94,7 @@ static function () use ($resetFingerprint, $fingerprint): string { $decimals = []; $encoded = []; -foreach ([8, 10, 12, 16, 20, 32] as $length) { +foreach ([8, 10, 12, 16, 20, 32, 64] as $length) { $material = ''; $counter = 0; diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 1f3de10..08a8ee8 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -55,9 +55,8 @@ private static function alphabet(int $base): string /** * @param list $bytes - * @return list */ - private static function appendDigit(array $bytes, int $base, int $digit): array + private static function appendDigit(array &$bytes, int $base, int $digit): void { $carry = $digit; for ($index = count($bytes) - 1; $index >= 0; --$index) { @@ -70,8 +69,6 @@ private static function appendDigit(array $bytes, int $base, int $digit): array array_unshift($bytes, $carry & 0xff); $carry >>= 8; } - - return array_values($bytes); } private static function assertByteLength(int $byteLength): void @@ -88,11 +85,7 @@ private static function byteString(array $bytes): string { $decoded = ''; foreach ($bytes as $byte) { - if ($byte < 0 || $byte > 255) { - throw new InvalidArgumentException('Byte value must be between 0 and 255'); - } - - $decoded .= chr($byte); + $decoded .= chr($byte & 0xff); } return $decoded; @@ -126,7 +119,7 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength throw new InvalidArgumentException('Invalid character for base ' . $base); } - $bytes = self::appendDigit($bytes, $base, $digit); + self::appendDigit($bytes, $base, $digit); if (count($bytes) > $bytesLength) { throw new InvalidArgumentException('Encoded value exceeds target byte length'); } @@ -139,9 +132,8 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength /** * @param list $number - * @return array{0:list,1:int} */ - private static function divide(array $number, int $base): array + private static function divide(array &$number, int $base): int { $quotient = []; $remainder = 0; @@ -154,7 +146,9 @@ private static function divide(array $number, int $base): array } } - return [$quotient, $remainder]; + $number = $quotient; + + return $remainder; } /** @@ -164,7 +158,7 @@ private static function encodeRadix(array $number, int $base, string $alphabet): { $encoded = ''; while ($number !== []) { - [$number, $remainder] = self::divide($number, $base); + $remainder = self::divide($number, $base); $encoded = $alphabet[$remainder] . $encoded; } From 82c74230cf101f9696898b0b96a1553da3e7af87 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:15:40 +0600 Subject: [PATCH 062/107] fix(analysis): preserve radix byte-list type --- src/Support/BaseEncoder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 08a8ee8..664d30d 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -111,6 +111,7 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength throw new InvalidArgumentException('Encoded value exceeds target byte length'); } + /** @var list $bytes */ $bytes = [0]; $length = strlen($encoded); for ($index = 0; $index < $length; ++$index) { From 246b32c56a1b7dabb1194bd11ad6b0c5b1543244 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:17:27 +0600 Subject: [PATCH 063/107] fix(analysis): widen private radix mutation contract --- src/Support/BaseEncoder.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 664d30d..7998157 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -53,14 +53,11 @@ private static function alphabet(int $base): string return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); } - /** - * @param list $bytes - */ private static function appendDigit(array &$bytes, int $base, int $digit): void { $carry = $digit; for ($index = count($bytes) - 1; $index >= 0; --$index) { - $value = ($bytes[$index] * $base) + $carry; + $value = (((int) $bytes[$index]) * $base) + $carry; $bytes[$index] = $value & 0xff; $carry = $value >> 8; } @@ -78,14 +75,11 @@ private static function assertByteLength(int $byteLength): void } } - /** - * @param list $bytes - */ private static function byteString(array $bytes): string { $decoded = ''; foreach ($bytes as $byte) { - $decoded .= chr($byte & 0xff); + $decoded .= chr(((int) $byte) & 0xff); } return $decoded; From 46876f5b82fa2e7c22c7a0f84470b9424588a2b4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:19:40 +0600 Subject: [PATCH 064/107] fix(analysis): declare radix mutation output type --- src/Support/BaseEncoder.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 7998157..fb8d489 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -53,11 +53,15 @@ private static function alphabet(int $base): string return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); } + /** + * @param array $bytes + * @param-out list $bytes + */ private static function appendDigit(array &$bytes, int $base, int $digit): void { $carry = $digit; for ($index = count($bytes) - 1; $index >= 0; --$index) { - $value = (((int) $bytes[$index]) * $base) + $carry; + $value = ($bytes[$index] * $base) + $carry; $bytes[$index] = $value & 0xff; $carry = $value >> 8; } @@ -75,11 +79,14 @@ private static function assertByteLength(int $byteLength): void } } + /** + * @param list $bytes + */ private static function byteString(array $bytes): string { $decoded = ''; foreach ($bytes as $byte) { - $decoded .= chr(((int) $byte) & 0xff); + $decoded .= chr($byte & 0xff); } return $decoded; From c3b86aced38a512987369930485ae5f2e77ae61e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:35:07 +0600 Subject: [PATCH 065/107] fix(analysis): preserve mutable radix list contract --- src/Support/BaseEncoder.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index fb8d489..664d30d 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -54,8 +54,7 @@ private static function alphabet(int $base): string } /** - * @param array $bytes - * @param-out list $bytes + * @param list $bytes */ private static function appendDigit(array &$bytes, int $base, int $digit): void { From a970afc72471fb1ee6abf6d94211c9363adeaaab Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:36:26 +0600 Subject: [PATCH 066/107] fix(analysis): widen radix mutation and normalize once --- src/Support/BaseEncoder.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 664d30d..fe3aa69 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -54,7 +54,8 @@ private static function alphabet(int $base): string } /** - * @param list $bytes + * @param array $bytes + * @param-out array $bytes */ private static function appendDigit(array &$bytes, int $base, int $digit): void { @@ -126,7 +127,7 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength } } - $decoded = self::byteString($bytes); + $decoded = self::byteString(array_values($bytes)); return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } From d37ea1f29786fdfdd052b853f52a2e4dd2c31c13 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:41:34 +0600 Subject: [PATCH 067/107] perf(codec): restore direct radix hot path --- src/Support/BaseEncoder.php | 175 +++++++++++++----------------------- 1 file changed, 61 insertions(+), 114 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index fe3aa69..0992db4 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -24,154 +24,73 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt if ($encoded === '') { throw new InvalidArgumentException('Encoded value must not be empty'); } - - self::assertByteLength($bytesLength); - if ($base === 16) { - return self::decodeHex($encoded, $bytesLength); - } - - 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); - if (trim($bytes, "\0") === '') { - return $alphabet[0]; - } - - return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); - } - - private static function alphabet(int $base): string - { - return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); - } - - /** - * @param array $bytes - * @param-out array $bytes - */ - private static function appendDigit(array &$bytes, int $base, int $digit): void - { - $carry = $digit; - for ($index = count($bytes) - 1; $index >= 0; --$index) { - $value = ($bytes[$index] * $base) + $carry; - $bytes[$index] = $value & 0xff; - $carry = $value >> 8; - } - - while ($carry > 0) { - array_unshift($bytes, $carry & 0xff); - $carry >>= 8; - } - } - - private static function assertByteLength(int $byteLength): void - { - if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { + if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) { throw new InvalidArgumentException('Byte length must be between 1 and 1024'); } - } - /** - * @param list $bytes - */ - private static function byteString(array $bytes): string - { - $decoded = ''; - foreach ($bytes as $byte) { - $decoded .= chr($byte & 0xff); - } + if ($base === 16) { + if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { + throw new InvalidArgumentException('Invalid character for base 16'); + } - return $decoded; - } + $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); + $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); - private static function decodeHex(string $encoded, int $bytesLength): string - { - if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { - throw new InvalidArgumentException('Invalid character for base 16'); + return $decoded; } - $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); - $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); - - return $decoded; - } - - private static function decodeRadix(string $encoded, int $base, int $bytesLength): string - { $alphabet = self::alphabet($base); $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2)); if (strlen($encoded) > $maximumLength) { throw new InvalidArgumentException('Encoded value exceeds target byte length'); } - /** @var list $bytes */ $bytes = [0]; $length = strlen($encoded); + for ($index = 0; $index < $length; ++$index) { $digit = strpos($alphabet, $encoded[$index]); if ($digit === false) { throw new InvalidArgumentException('Invalid character for base ' . $base); } - self::appendDigit($bytes, $base, $digit); + $carry = $digit; + for ($byteIndex = count($bytes) - 1; $byteIndex >= 0; --$byteIndex) { + $value = ($bytes[$byteIndex] * $base) + $carry; + $bytes[$byteIndex] = $value & 0xff; + $carry = $value >> 8; + } + + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; + } + if (count($bytes) > $bytesLength) { throw new InvalidArgumentException('Encoded value exceeds target byte length'); } } - $decoded = self::byteString(array_values($bytes)); + $decoded = ''; + foreach ($bytes as $byte) { + $decoded .= chr($byte); + } return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - /** - * @param list $number - */ - private static function divide(array &$number, int $base): int + public static function encodeBytes(string $bytes, int $base): string { - $quotient = []; - $remainder = 0; - foreach ($number as $byte) { - $value = ($remainder << 8) | $byte; - $digit = intdiv($value, $base); - $remainder = $value % $base; - if ($quotient !== [] || $digit !== 0) { - $quotient[] = $digit; - } + $byteLength = strlen($bytes); + if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); } - $number = $quotient; - - return $remainder; - } - - /** - * @param list $number - */ - private static function encodeRadix(array $number, int $base, string $alphabet): string - { - $encoded = ''; - while ($number !== []) { - $remainder = self::divide($number, $base); - $encoded = $alphabet[$remainder] . $encoded; + if ($base === 16) { + return ltrim(bin2hex($bytes), '0') ?: '0'; } - return $encoded; - } - - /** - * @return list - */ - private static function unpackBytes(string $bytes): array - { + $alphabet = self::alphabet($base); $unpacked = unpack('C*', $bytes); $unpacked !== false || throw new \LogicException('Unable to unpack byte value'); @@ -181,6 +100,34 @@ private static function unpackBytes(string $bytes): array $number[] = $byte; } - return $number; + 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; + } + + return $encoded; + } + + private static function alphabet(int $base): string + { + return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); } } From d7eb2443b5519b5745ff80bcda9d8547fc09a261 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:43:33 +0600 Subject: [PATCH 068/107] perf(codec): keep radix loops direct within complexity budget --- src/Support/BaseEncoder.php | 97 ++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 0992db4..8329533 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -24,21 +24,55 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt 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'); - } + self::assertByteLength($bytesLength); + + return $base === 16 + ? self::decodeHex($encoded, $bytesLength) + : self::decodeRadix($encoded, $base, $bytesLength); + } + + public static function encodeBytes(string $bytes, int $base): string + { + self::assertByteLength(strlen($bytes)); if ($base === 16) { - if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { - throw new InvalidArgumentException('Invalid character for base 16'); - } + return ltrim(bin2hex($bytes), '0') ?: '0'; + } + + $alphabet = self::alphabet($base); + if (trim($bytes, "\0") === '') { + return $alphabet[0]; + } + + return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); + } - $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); - $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); + private static function alphabet(int $base): string + { + return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); + } - return $decoded; + 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 decodeHex(string $encoded, int $bytesLength): string + { + if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { + throw new InvalidArgumentException('Invalid character for base 16'); + } + + $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); + $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); + + return $decoded; + } + + private static function decodeRadix(string $encoded, int $base, int $bytesLength): string + { $alphabet = self::alphabet($base); $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2)); if (strlen($encoded) > $maximumLength) { @@ -79,32 +113,13 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - public static function encodeBytes(string $bytes, int $base): string + /** + * @param list $number + */ + private static function encodeRadix(array $number, 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; @@ -126,8 +141,20 @@ public static function encodeBytes(string $bytes, int $base): string return $encoded; } - private static function alphabet(int $base): string + /** + * @return list + */ + private static function unpackBytes(string $bytes): array { - return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $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; + } + + return $number; } } From 3ab18a15433e9064dc4d21cfbe11c4d0df700453 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:45:32 +0600 Subject: [PATCH 069/107] perf(lock): collapse secure-open warning handling --- src/Support/FileLock.php | 104 +++++++++++---------------------------- 1 file changed, 30 insertions(+), 74 deletions(-) diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index ffe9127..cabfb26 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -64,8 +64,7 @@ public static function acquire( */ private static function assertSafeMetadata(array $metadata, string $errorMessage): void { - $mode = $metadata['mode']; - if (($mode & 0170000) !== 0100000) { + if (($metadata['mode'] & 0170000) !== 0100000) { throw new FileLockException($errorMessage); } @@ -85,22 +84,11 @@ private static function assertSameFile(array $left, array $right, string $errorM } } - private static function changePermissions(string $path, int $permissions): bool - { - try { - return self::invokeFilesystem(static fn(): bool => chmod($path, $permissions)); - } catch (ErrorException) { - return false; - } - } - /** - * @template T - * @param callable():T $operation - * @return T - * @throws ErrorException + * @return resource + * @throws FileLockException */ - private static function invokeFilesystem(callable $operation): mixed + private static function openVerified(string $path, string $errorMessage) { set_error_handler( static function (int $severity, string $message, string $file, int $line): never { @@ -109,91 +97,59 @@ static function (int $severity, string $message, string $file, int $line): never ); try { - return $operation(); + return self::openVerifiedWithHandler($path, $errorMessage); + } catch (FileLockException $exception) { + throw $exception; + } catch (ErrorException $exception) { + throw new FileLockException($errorMessage, 0, $exception); } finally { restore_error_handler(); } } /** - * @param array $before * @return resource + * @throws FileLockException + * @throws ErrorException */ - private static function openExisting(string $path, array $before, string $errorMessage) - { - $handle = self::openStream($path, 'r+b'); - if (!is_resource($handle)) { - throw new FileLockException($errorMessage); - } - - return self::verifyHandle($path, $handle, $before, $errorMessage); - } - - /** - * @return resource|false - */ - private static function openStream(string $path, string $mode) + private static function openVerifiedWithHandler(string $path, string $errorMessage) { try { - return self::invokeFilesystem(static fn() => fopen($path, $mode)); + $before = lstat($path); } catch (ErrorException) { - return false; + $before = false; } - } - /** - * @return resource - * @throws FileLockException - */ - private static function openVerified(string $path, string $errorMessage) - { - $before = self::pathMetadata($path); if ($before !== false) { self::assertSafeMetadata($before, $errorMessage); + $handle = fopen($path, 'r+b'); + is_resource($handle) || throw new FileLockException($errorMessage); - return self::openExisting($path, $before, $errorMessage); + return self::verifyHandle($path, $handle, $before, $errorMessage); } - $handle = self::openStream($path, 'x+b'); - if (!is_resource($handle)) { - $before = self::pathMetadata($path); - if ($before === false) { - throw new FileLockException($errorMessage); - } - + try { + $handle = fopen($path, 'x+b'); + } catch (ErrorException) { + $before = lstat($path); + $before !== false || throw new FileLockException($errorMessage); self::assertSafeMetadata($before, $errorMessage); - return self::openExisting($path, $before, $errorMessage); - } - - if (!self::changePermissions($path, 0600)) { - fclose($handle); + $handle = fopen($path, 'r+b'); + is_resource($handle) || throw new FileLockException($errorMessage); - throw new FileLockException($errorMessage); + return self::verifyHandle($path, $handle, $before, $errorMessage); } - return self::verifyHandle($path, $handle, null, $errorMessage); - } + is_resource($handle) || throw new FileLockException($errorMessage); + chmod($path, 0600) || throw new FileLockException($errorMessage); - /** - * @return array|false - */ - private static function pathMetadata(string $path): array|false - { - try { - return self::invokeFilesystem(static fn(): array|false => lstat($path)); - } catch (ErrorException) { - return false; - } + return self::verifyHandle($path, $handle, null, $errorMessage); } private static function runtimeTimeout(?GenerationContext $runtime): int { - if ($runtime === null) { - return self::DEFAULT_TIMEOUT_MICROS; - } - - return $runtime->waitTimeoutMicros; + return $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; } /** @@ -205,7 +161,7 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri { try { $after = fstat($handle); - $pathState = self::pathMetadata($path); + $pathState = lstat($path); if ($after === false || $pathState === false) { throw new FileLockException($errorMessage); } From 14dd0c4c60ee949785c0d7d9939a3b81956cf3dc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:47:26 +0600 Subject: [PATCH 070/107] fix(analysis): make runtime timeout branch explicit --- src/Support/FileLock.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index cabfb26..0620954 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -149,7 +149,9 @@ private static function openVerifiedWithHandler(string $path, string $errorMessa private static function runtimeTimeout(?GenerationContext $runtime): int { - return $runtime?->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + return $runtime instanceof GenerationContext + ? $runtime->waitTimeoutMicros + : self::DEFAULT_TIMEOUT_MICROS; } /** From 7e526f3935fa4e350ffab535b5937d96eed07220 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:48:48 +0600 Subject: [PATCH 071/107] fix(qa): remove redundant lock exception catch --- src/Support/FileLock.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 0620954..a60e57f 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -98,8 +98,6 @@ static function (int $severity, string $message, string $file, int $line): never try { return self::openVerifiedWithHandler($path, $errorMessage); - } catch (FileLockException $exception) { - throw $exception; } catch (ErrorException $exception) { throw new FileLockException($errorMessage, 0, $exception); } finally { From 4dda9ae057741b92104b15f56a802d65b8bf3a82 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:52:20 +0600 Subject: [PATCH 072/107] perf(codec): inline cheap hex boundary checks --- src/Support/BaseEncoder.php | 43 ++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 8329533..95f70cf 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -24,16 +24,30 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt if ($encoded === '') { throw new InvalidArgumentException('Encoded value must not be empty'); } - self::assertByteLength($bytesLength); + if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) { + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); + } + + if ($base === 16) { + if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { + throw new InvalidArgumentException('Invalid character for base 16'); + } + + $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); + $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); - return $base === 16 - ? self::decodeHex($encoded, $bytesLength) - : self::decodeRadix($encoded, $base, $bytesLength); + return $decoded; + } + + return self::decodeRadix($encoded, $base, $bytesLength); } public static function encodeBytes(string $bytes, int $base): string { - self::assertByteLength(strlen($bytes)); + $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'; @@ -52,25 +66,6 @@ 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 decodeHex(string $encoded, int $bytesLength): string - { - if (strlen($encoded) > $bytesLength * 2 || preg_match('/^[0-9a-f]+$/D', $encoded) !== 1) { - throw new InvalidArgumentException('Invalid character for base 16'); - } - - $decoded = hex2bin(str_pad($encoded, $bytesLength * 2, '0', STR_PAD_LEFT)); - $decoded !== false || throw new InvalidArgumentException('Unable to decode base 16 value'); - - return $decoded; - } - private static function decodeRadix(string $encoded, int $base, int $bytesLength): string { $alphabet = self::alphabet($base); From d6c62b99d92c91ed4856c17e136a175433b740c0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:53:57 +0600 Subject: [PATCH 073/107] bench(host): use sustained fixed-duration trials --- benchmarks/release/HostBenchmark.php | 413 +++++++++++++++------------ 1 file changed, 227 insertions(+), 186 deletions(-) diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index e23e9bd..d7e474a 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -2,19 +2,57 @@ declare(strict_types=1); -$options = getopt('', ['base-url:', 'release:', 'output:']); +$options = getopt('', [ + 'base-url:', + 'release:', + 'output:', + 'route:', + 'concurrency:', + 'duration:', + 'repetitions:', + 'ids-per-response:', + 'warmup:', +]); + $baseUrl = $options['base-url'] ?? null; $release = $options['release'] ?? null; $output = $options['output'] ?? null; - -if (!is_string($baseUrl) || $baseUrl === '' || !is_string($release) || $release === '' || !is_string($output) || $output === '') { - throw new InvalidArgumentException('Usage: php HostBenchmark.php --base-url=URL --release=NAME --output=FILE'); +$route = $options['route'] ?? null; +$concurrency = filter_var($options['concurrency'] ?? null, FILTER_VALIDATE_INT); +$duration = filter_var($options['duration'] ?? null, FILTER_VALIDATE_INT); +$repetitions = filter_var($options['repetitions'] ?? null, FILTER_VALIDATE_INT); +$idsPerResponse = filter_var($options['ids-per-response'] ?? null, FILTER_VALIDATE_INT); +$warmup = filter_var($options['warmup'] ?? null, FILTER_VALIDATE_INT); + +if ( + !is_string($baseUrl) + || $baseUrl === '' + || !is_string($release) + || $release === '' + || !is_string($output) + || $output === '' + || !is_string($route) + || $route === '' + || !is_int($concurrency) + || $concurrency < 1 + || !is_int($duration) + || $duration < 1 + || !is_int($repetitions) + || $repetitions < 3 + || !is_int($idsPerResponse) + || $idsPerResponse < 1 + || !is_int($warmup) + || $warmup < 1 +) { + throw new InvalidArgumentException('Invalid fixed-duration host benchmark configuration'); } if (!extension_loaded('curl')) { throw new RuntimeException('The curl extension is required for host benchmarking'); } +const UID_MAX_LATENCY_SAMPLES = 200_000; + /** * @param list $values */ @@ -38,102 +76,130 @@ function uidAverage(array $values): float return $values === [] ? 0.0 : array_sum($values) / count($values); } +function uidCreateHandle(string $url): CurlHandle +{ + $handle = curl_init($url); + $handle instanceof CurlHandle || throw new RuntimeException('Unable to create benchmark request handle'); + + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT_MS => 2_000, + CURLOPT_TIMEOUT_MS => 5_000, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + return $handle; +} + +/** + * @param array{result:int,handle:CurlHandle} $info + * @return array{successful:bool,timeout:bool,latency:float,duplicates:int} + */ +function uidInspectCompletion(array $info, int $idsPerResponse): array +{ + $handle = $info['handle']; + $body = curl_multi_getcontent($handle); + $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $latency = (float) curl_getinfo($handle, CURLINFO_TOTAL_TIME) * 1_000; + $successful = $info['result'] === CURLE_OK && $httpCode === 200 && is_string($body); + $decoded = $successful ? json_decode($body, true) : null; + $ids = is_array($decoded) ? ($decoded['ids'] ?? null) : null; + + if (!is_array($ids) || count($ids) !== $idsPerResponse) { + $successful = false; + $ids = []; + } + + $duplicates = 0; + $responseIds = []; + + foreach ($ids as $id) { + if (!is_string($id) || $id === '') { + $successful = false; + + continue; + } + + if (isset($responseIds[$id])) { + ++$duplicates; + } else { + $responseIds[$id] = true; + } + } + + return [ + 'successful' => $successful, + 'timeout' => $info['result'] === CURLE_OPERATION_TIMEDOUT, + 'latency' => $latency, + 'duplicates' => $duplicates, + ]; +} + /** * @return array{ - * attempted:int,successful:int,failed:int,timeouts:int,rpm:float, - * latencies:list,duplicates:int + * attempted:int, + * successful:int, + * failed:int, + * timeouts:int, + * rpm:float, + * latencies:list, + * duplicates:int, + * elapsed_seconds:float * } */ -function uidRunLoad(string $url, int $concurrency, int $operations, int $idsPerResponse): array -{ +function uidRunDuration( + string $url, + int $concurrency, + int $durationSeconds, + int $idsPerResponse, +): array { $multi = curl_multi_init(); - $launched = 0; $active = 0; + $attempted = 0; $successful = 0; $failed = 0; $timeouts = 0; - $latencies = []; - $seen = []; $duplicates = 0; + $latencies = []; + $started = hrtime(true); + $stopAt = $started + ($durationSeconds * 1_000_000_000); - $launch = static function () use ( - $multi, - $url, - &$launched, - &$active, - $operations, - ): void { - if ($launched >= $operations) { - return; - } - - $handle = curl_init($url); - curl_setopt_array($handle, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT_MS => 2_000, - CURLOPT_TIMEOUT_MS => 5_000, - CURLOPT_HTTPHEADER => ['Accept: application/json'], - ]); - curl_multi_add_handle($multi, $handle); - ++$launched; + for ($index = 0; $index < $concurrency; ++$index) { + curl_multi_add_handle($multi, uidCreateHandle($url)); ++$active; - }; - - for ($index = 0; $index < min($concurrency, $operations); ++$index) { - $launch(); } - $started = hrtime(true); - while ($active > 0) { do { $status = curl_multi_exec($multi, $running); } while ($status === CURLM_CALL_MULTI_PERFORM); - if ($status !== CURLM_OK) { - ++$failed; - - break; - } + $status === CURLM_OK || throw new RuntimeException('Host benchmark curl multi execution failed'); while (($info = curl_multi_info_read($multi)) !== false) { - $handle = $info['handle']; - $body = curl_multi_getcontent($handle); - $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); - $latency = (float) curl_getinfo($handle, CURLINFO_TOTAL_TIME) * 1_000; + $result = uidInspectCompletion($info, $idsPerResponse); + ++$attempted; - $valid = $info['result'] === CURLE_OK && $httpCode === 200 && is_string($body); - $decoded = $valid ? json_decode($body, true) : null; - $ids = is_array($decoded) ? ($decoded['ids'] ?? null) : null; - - if (!is_array($ids) || count($ids) !== $idsPerResponse || array_filter($ids, is_string(...)) !== $ids) { - $valid = false; - } - - if ($valid) { + if ($result['successful']) { ++$successful; - $latencies[] = $latency; - - foreach ($ids as $id) { - if (isset($seen[$id])) { - ++$duplicates; - } else { - $seen[$id] = true; - } + if (count($latencies) < UID_MAX_LATENCY_SAMPLES) { + $latencies[] = $result['latency']; } } else { ++$failed; - if ($info['result'] === CURLE_OPERATION_TIMEDOUT) { - ++$timeouts; - } } - curl_multi_remove_handle($multi, $handle); - unset($handle); + if ($result['timeout']) { + ++$timeouts; + } + + $duplicates += $result['duplicates']; + curl_multi_remove_handle($multi, $info['handle']); --$active; - if ($launched < $operations) { - $launch(); + if (hrtime(true) < $stopAt) { + curl_multi_add_handle($multi, uidCreateHandle($url)); + ++$active; } } @@ -146,16 +212,18 @@ function uidRunLoad(string $url, int $concurrency, int $operations, int $idsPerR } unset($multi); - $seconds = max((hrtime(true) - $started) / 1_000_000_000, 0.000001); + + $elapsed = max((hrtime(true) - $started) / 1_000_000_000, 0.000001); return [ - 'attempted' => $operations, + 'attempted' => $attempted, 'successful' => $successful, - 'failed' => $failed + max(0, $operations - $successful - $failed), + 'failed' => $failed, 'timeouts' => $timeouts, - 'rpm' => ($successful / $seconds) * 60, + 'rpm' => ($successful / $elapsed) * 60, 'latencies' => $latencies, 'duplicates' => $duplicates, + 'elapsed_seconds' => $elapsed, ]; } @@ -186,6 +254,7 @@ function uidEnvironment(string $release): array 'extensions' => $extensions, 'runner' => (string) (getenv('RUNNER_NAME') ?: 'github-actions'), ]; + $fingerprintSource = $environment; $environment['fingerprint'] = hash( 'sha256', @@ -196,125 +265,97 @@ function uidEnvironment(string $release): array return $environment; } -$workloadDefinitions = [ - [ - 'route' => 'cuid2-one', - 'ids_per_response' => 1, - 'operations' => 1_000, - ], - [ - 'route' => 'cuid2-batch', - 'ids_per_response' => 100, - 'operations' => 150, - ], - [ - 'route' => 'snowflake-contended', - 'ids_per_response' => 1, - 'operations' => 800, - ], -]; -$concurrencies = [1, 5, 20, 50]; -$repetitions = 5; -$warmupOperations = 40; -$workloads = []; -$overallFailure = false; - -foreach ($workloadDefinitions as $definition) { - foreach ($concurrencies as $concurrency) { - $url = rtrim($baseUrl, '/') . '/' . $definition['route']; - uidRunLoad( - $url, - $concurrency, - $warmupOperations, - $definition['ids_per_response'], - ); - - $rpms = []; - $latencies = []; - $attempted = 0; - $successful = 0; - $failed = 0; - $timeouts = 0; - $duplicates = 0; - - for ($repetition = 0; $repetition < $repetitions; ++$repetition) { - $result = uidRunLoad( - $url, - $concurrency, - $definition['operations'], - $definition['ids_per_response'], - ); - $rpms[] = $result['rpm']; - $latencies = [...$latencies, ...$result['latencies']]; - $attempted += $result['attempted']; - $successful += $result['successful']; - $failed += $result['failed']; - $timeouts += $result['timeouts']; - $duplicates += $result['duplicates']; - } - - sort($rpms, SORT_NUMERIC); - $medianRpm = uidPercentile($rpms, 0.50); - $spread = $medianRpm > 0 - ? ((uidPercentile($rpms, 0.75) - uidPercentile($rpms, 0.25)) / $medianRpm) * 100 - : 100.0; - $stable = $spread <= 15.0 && $failed === 0 && $duplicates === 0; +$url = rtrim($baseUrl, '/') . '/' . ltrim($route, '/'); +$warmupResult = uidRunDuration($url, $concurrency, $warmup, $idsPerResponse); - if (!$stable) { - $overallFailure = true; - } +if ( + $warmupResult['failed'] !== 0 + || $warmupResult['timeouts'] !== 0 + || $warmupResult['duplicates'] !== 0 +) { + throw new RuntimeException('Host benchmark warmup produced invalid responses'); +} - $workloads[] = [ - 'name' => $definition['route'] . '-c' . $concurrency, - 'type' => 'http', - 'metadata' => [ - 'route' => '/' . $definition['route'], - 'operations_per_repetition' => $definition['operations'], - 'ids_per_response' => $definition['ids_per_response'], - 'duplicate_ids' => $duplicates, - ], - 'repetitions' => $repetitions, - 'warmup_operations' => $warmupOperations, - 'duration_seconds' => 0, - 'concurrency' => $concurrency, - 'result' => [ - 'attempted_operations' => $attempted, - 'successful_operations' => $successful, - 'failed_operations' => $failed, - 'timeouts' => $timeouts, - 'successful_rpm' => round($medianRpm, 5), - 'error_rate' => $attempted === 0 ? 0.0 : $failed / $attempted, - 'latency_ms' => [ - 'minimum' => $latencies === [] ? null : round(min($latencies), 5), - 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), - 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), - 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), - 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), - 'maximum' => $latencies === [] ? null : round(max($latencies), 5), - ], - 'cpu' => [ - 'average_percent' => null, - 'peak_percent' => null, - ], - 'memory' => [ - 'average_mb' => null, - 'peak_mb' => null, - 'growth_mb' => null, - ], - 'stability' => [ - 'status' => $stable ? 'stable' : 'unstable', - 'spread_percent' => round($spread, 5), - ], - ], - ]; +$rpms = []; +$latencies = []; +$attempted = 0; +$successful = 0; +$failed = 0; +$timeouts = 0; +$duplicates = 0; +$elapsedSeconds = 0.0; + +for ($repetition = 0; $repetition < $repetitions; ++$repetition) { + $result = uidRunDuration($url, $concurrency, $duration, $idsPerResponse); + $rpms[] = $result['rpm']; + $attempted += $result['attempted']; + $successful += $result['successful']; + $failed += $result['failed']; + $timeouts += $result['timeouts']; + $duplicates += $result['duplicates']; + $elapsedSeconds += $result['elapsed_seconds']; + + $remaining = UID_MAX_LATENCY_SAMPLES - count($latencies); + if ($remaining > 0) { + $latencies = [...$latencies, ...array_slice($result['latencies'], 0, $remaining)]; } } +sort($rpms, SORT_NUMERIC); +$medianRpm = uidPercentile($rpms, 0.50); +$spread = $medianRpm > 0 + ? ((uidPercentile($rpms, 0.75) - uidPercentile($rpms, 0.25)) / $medianRpm) * 100 + : 100.0; +$stable = $spread <= 15.0 && $failed === 0 && $duplicates === 0 && $timeouts === 0; +$name = $route . '-c' . $concurrency; + $document = [ 'schema_version' => 1, 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), 'environment' => uidEnvironment($release), - 'workloads' => $workloads, + 'workloads' => [[ + 'name' => $name, + 'type' => 'http', + 'metadata' => [ + 'route' => '/' . ltrim($route, '/'), + 'trial_duration_seconds' => $duration, + 'ids_per_response' => $idsPerResponse, + 'duplicate_ids' => $duplicates, + ], + 'repetitions' => $repetitions, + 'warmup_operations' => $warmupResult['attempted'], + 'duration_seconds' => round($elapsedSeconds, 5), + 'concurrency' => $concurrency, + 'result' => [ + 'attempted_operations' => $attempted, + 'successful_operations' => $successful, + 'failed_operations' => $failed, + 'timeouts' => $timeouts, + 'successful_rpm' => round($medianRpm, 5), + 'error_rate' => $attempted === 0 ? 0.0 : $failed / $attempted, + 'latency_ms' => [ + 'minimum' => $latencies === [] ? null : round(min($latencies), 5), + 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), + 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), + 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), + 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), + 'maximum' => $latencies === [] ? null : round(max($latencies), 5), + ], + 'cpu' => [ + 'average_percent' => null, + 'peak_percent' => null, + ], + 'memory' => [ + 'average_mb' => null, + 'peak_mb' => null, + 'growth_mb' => null, + ], + 'stability' => [ + 'status' => $stable ? 'stable' : 'unstable', + 'spread_percent' => round($spread, 5), + ], + ], + ]], ]; file_put_contents( @@ -322,6 +363,6 @@ function uidEnvironment(string $release): array json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, ); -if ($overallFailure) { - throw new RuntimeException('Host benchmark produced unstable, erroneous or duplicate-bearing samples'); +if (!$stable) { + throw new RuntimeException('Host benchmark did not reach a stable valid state'); } From 060b41fe54312a88c79ad523b902d9cff9ec5db9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:54:50 +0600 Subject: [PATCH 074/107] ci(release): run sustained paired host acceptance --- .github/workflows/release-acceptance.yml | 168 ++++++++++++++++++++--- 1 file changed, 149 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 8975590..7409402 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -14,14 +14,16 @@ permissions: contents: read jobs: - release-acceptance: + diagnostics: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 15 env: XDEBUG_MODE: off steps: - - name: Checkout candidate tooling + - name: Checkout exact candidate uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup PHP 8.4 uses: shivammathur/setup-php@v2 @@ -47,7 +49,7 @@ jobs: ref: "5.0" path: ".release-baseline" - - name: Checkout candidate target + - name: Checkout candidate production target uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} @@ -92,10 +94,108 @@ jobs: cat .phpforge-report/contention-baseline.csv cat .phpforge-report/contention-candidate.csv - - name: Benchmark tag 5.0 host routes + - name: Profile Runwire-bound path separately + shell: bash + run: | + php benchmarks/release/RunwireProfile.php .phpforge-report/runwire-profile.json + cat .phpforge-report/runwire-profile.json + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: uid-6-release-diagnostics + path: .phpforge-report + retention-days: 14 + if-no-files-found: error + + host-performance: + needs: diagnostics + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - route: cuid2-one + concurrency: 1 + ids_per_response: 1 + - route: cuid2-one + concurrency: 5 + ids_per_response: 1 + - route: cuid2-one + concurrency: 20 + ids_per_response: 1 + - route: cuid2-one + concurrency: 50 + ids_per_response: 1 + - route: cuid2-batch + concurrency: 1 + ids_per_response: 100 + - route: cuid2-batch + concurrency: 5 + ids_per_response: 100 + - route: cuid2-batch + concurrency: 20 + ids_per_response: 100 + - route: cuid2-batch + concurrency: 50 + ids_per_response: 100 + - route: snowflake-contended + concurrency: 1 + ids_per_response: 1 + - route: snowflake-contended + concurrency: 5 + ids_per_response: 1 + - route: snowflake-contended + concurrency: 20 + ids_per_response: 1 + - route: snowflake-contended + concurrency: 50 + ids_per_response: 1 + env: + XDEBUG_MODE: off + steps: + - name: Checkout exact candidate tooling + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout tag 5.0 + uses: actions/checkout@v7 + with: + ref: "5.0" + path: ".release-baseline" + + - name: Checkout candidate production target + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: ".release-candidate" + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + extensions: ctype,curl + coverage: none + + - name: Install benchmark tooling and targets + shell: bash + run: | + composer install --no-interaction --prefer-dist --no-progress + composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + mkdir -p .phpforge-report "$RUNNER_TEMP/uid-baseline-state" "$RUNNER_TEMP/uid-candidate-state" + + - name: Benchmark tag 5.0 shell: bash + env: + ROUTE: ${{ matrix.route }} + CONCURRENCY: ${{ matrix.concurrency }} + IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} run: | - mkdir -p "$RUNNER_TEMP/uid-baseline-state" UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18080 benchmarks/release/host-router.php > .phpforge-report/baseline-server.log 2>&1 & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT @@ -106,16 +206,19 @@ jobs: done curl --fail --silent http://127.0.0.1:18080/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=5 kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true trap - EXIT - - name: Benchmark candidate host routes + - name: Benchmark candidate shell: bash + env: + ROUTE: ${{ matrix.route }} + CONCURRENCY: ${{ matrix.concurrency }} + IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} run: | - mkdir -p "$RUNNER_TEMP/uid-candidate-state" UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18081 benchmarks/release/host-router.php > .phpforge-report/candidate-server.log 2>&1 & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT @@ -126,29 +229,57 @@ jobs: done curl --fail --silent http://127.0.0.1:18081/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=5 kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true trap - EXIT - - name: Enforce host benchmark contract and 2 percent budget + - name: Enforce 2 percent host budget shell: bash run: | composer ic:benchmark:validate .phpforge-report/host-baseline.json composer ic:benchmark:validate .phpforge-report/host-candidate.json composer ic:benchmark:compare .phpforge-report/host-baseline.json .phpforge-report/host-candidate.json --max-regression=2 --stable-environment - - name: Profile Runwire-bound path separately + - name: Upload host evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: uid-host-${{ matrix.route }}-c${{ matrix.concurrency }} + path: .phpforge-report + retention-days: 14 + if-no-files-found: error + + persistent-worker-soak: + needs: diagnostics + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + XDEBUG_MODE: off + UID_SOAK_RESULT: ${{ github.workspace }}/.phpforge-report/uid-soak-internal.json + steps: + - name: Checkout exact candidate + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + extensions: ctype,pcntl + coverage: none + + - name: Install candidate tooling shell: bash run: | - php benchmarks/release/RunwireProfile.php .phpforge-report/runwire-profile.json - cat .phpforge-report/runwire-profile.json + composer install --no-interaction --prefer-dist --no-progress + mkdir -p .phpforge-report - name: Run five-minute persistent-worker soak shell: bash - env: - UID_SOAK_RESULT: ${{ github.workspace }}/.phpforge-report/uid-soak-internal.json run: | composer ic:soak:worker --duration=300 --warmup=10 --sample-interval=2 --max-growth-mb=32 --report=.phpforge-report/phpforge-soak.json -- php benchmarks/release/soak-worker.php @@ -161,12 +292,11 @@ jobs: echo json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES),PHP_EOL; ' - - name: Upload release acceptance evidence + - name: Upload soak evidence if: always() uses: actions/upload-artifact@v7 with: - name: uid-6-release-acceptance + name: uid-6-persistent-worker-soak path: .phpforge-report retention-days: 14 if-no-files-found: error - include-hidden-files: true From 30dc7e1d1edeea7a25946e87c8e31b1e21a48b8e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:57:25 +0600 Subject: [PATCH 075/107] fix(bench): bootstrap Runwire profiling --- benchmarks/release/RunwireProfile.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benchmarks/release/RunwireProfile.php b/benchmarks/release/RunwireProfile.php index 3b4b833..14d2a5f 100644 --- a/benchmarks/release/RunwireProfile.php +++ b/benchmarks/release/RunwireProfile.php @@ -2,6 +2,8 @@ declare(strict_types=1); +require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; + use Infocyph\Runwire\Coroutine\CoroutineRuntime; use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\RequestContext; From 2fa02dd933ea5cc4d8acd5d4064c7a26b5692320 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 09:57:32 +0600 Subject: [PATCH 076/107] fix(ci): retain hidden release evidence --- .github/workflows/release-acceptance.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 7409402..f382c42 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -108,6 +108,7 @@ jobs: path: .phpforge-report retention-days: 14 if-no-files-found: error + include-hidden-files: true host-performance: needs: diagnostics @@ -250,6 +251,7 @@ jobs: path: .phpforge-report retention-days: 14 if-no-files-found: error + include-hidden-files: true persistent-worker-soak: needs: diagnostics @@ -300,3 +302,4 @@ jobs: path: .phpforge-report retention-days: 14 if-no-files-found: error + include-hidden-files: true From 3d144c9ef548125d70357796da915caac92c8e45 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:06:47 +0600 Subject: [PATCH 077/107] fix(bench): separate reproduction inputs from measured results --- benchmarks/release/HostBenchmark.php | 87 ++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index d7e474a..b75b0f0 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -135,6 +135,84 @@ function uidInspectCompletion(array $info, int $idsPerResponse): array ]; } +/** + * @return array{attempted:int,successful:int,failed:int,timeouts:int,duplicates:int} + */ +function uidRunOperations( + string $url, + int $concurrency, + int $operations, + int $idsPerResponse, +): array { + $multi = curl_multi_init(); + $launched = 0; + $active = 0; + $attempted = 0; + $successful = 0; + $failed = 0; + $timeouts = 0; + $duplicates = 0; + + $launch = static function () use ($multi, $url, $operations, &$launched, &$active): void { + if ($launched >= $operations) { + return; + } + + curl_multi_add_handle($multi, uidCreateHandle($url)); + ++$launched; + ++$active; + }; + + for ($index = 0; $index < min($concurrency, $operations); ++$index) { + $launch(); + } + + while ($active > 0) { + do { + $status = curl_multi_exec($multi, $running); + } while ($status === CURLM_CALL_MULTI_PERFORM); + + $status === CURLM_OK || throw new RuntimeException('Host benchmark warmup execution failed'); + + while (($info = curl_multi_info_read($multi)) !== false) { + $result = uidInspectCompletion($info, $idsPerResponse); + ++$attempted; + + if ($result['successful']) { + ++$successful; + } else { + ++$failed; + } + + if ($result['timeout']) { + ++$timeouts; + } + + $duplicates += $result['duplicates']; + curl_multi_remove_handle($multi, $info['handle']); + --$active; + $launch(); + } + + if ($running > 0) { + $selected = curl_multi_select($multi, 0.5); + if ($selected === -1) { + usleep(1_000); + } + } + } + + unset($multi); + + return [ + 'attempted' => $attempted, + 'successful' => $successful, + 'failed' => $failed, + 'timeouts' => $timeouts, + 'duplicates' => $duplicates, + ]; +} + /** * @return array{ * attempted:int, @@ -266,7 +344,7 @@ function uidEnvironment(string $release): array } $url = rtrim($baseUrl, '/') . '/' . ltrim($route, '/'); -$warmupResult = uidRunDuration($url, $concurrency, $warmup, $idsPerResponse); +$warmupResult = uidRunOperations($url, $concurrency, $warmup, $idsPerResponse); if ( $warmupResult['failed'] !== 0 @@ -320,19 +398,20 @@ function uidEnvironment(string $release): array 'route' => '/' . ltrim($route, '/'), 'trial_duration_seconds' => $duration, 'ids_per_response' => $idsPerResponse, - 'duplicate_ids' => $duplicates, ], 'repetitions' => $repetitions, - 'warmup_operations' => $warmupResult['attempted'], - 'duration_seconds' => round($elapsedSeconds, 5), + 'warmup_operations' => $warmup, + 'duration_seconds' => $duration * $repetitions, 'concurrency' => $concurrency, 'result' => [ 'attempted_operations' => $attempted, 'successful_operations' => $successful, 'failed_operations' => $failed, 'timeouts' => $timeouts, + 'duplicate_ids' => $duplicates, 'successful_rpm' => round($medianRpm, 5), 'error_rate' => $attempted === 0 ? 0.0 : $failed / $attempted, + 'measured_elapsed_seconds' => round($elapsedSeconds, 5), 'latency_ms' => [ 'minimum' => $latencies === [] ? null : round(min($latencies), 5), 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), From 1f338a6928b248b688769b738bd469a24edfe4b9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:07:03 +0600 Subject: [PATCH 078/107] bench(host): use fixed comparable warmup operations --- .github/workflows/release-acceptance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index f382c42..066f179 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -207,7 +207,7 @@ jobs: done curl --fail --silent http://127.0.0.1:18080/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=5 + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=500 kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true @@ -230,7 +230,7 @@ jobs: done curl --fail --silent http://127.0.0.1:18081/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=5 + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=500 kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true From e247dd1d8a392c8ea83131a11bcdabe1c2d48873 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:21:27 +0600 Subject: [PATCH 079/107] perf(codec): restore inline base encoding hot path --- src/Support/BaseEncoder.php | 74 +++++++++++++++---------------------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 95f70cf..0a375c9 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -54,11 +54,39 @@ public static function encodeBytes(string $bytes, int $base): string } $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]; } - return self::encodeRadix(self::unpackBytes($bytes), $base, $alphabet); + $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; + } + + return $encoded; } private static function alphabet(int $base): string @@ -108,48 +136,4 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - /** - * @param list $number - */ - private static function encodeRadix(array $number, int $base, string $alphabet): string - { - $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; - } - - return $encoded; - } - - /** - * @return list - */ - private static function unpackBytes(string $bytes): array - { - $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; - } - - return $number; - } } From 8e7b0b4e9f40a8846195fe474f631c3aaa265e5a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:21:42 +0600 Subject: [PATCH 080/107] perf(lock): fast-path uncontended verified locks --- src/Support/FileLock.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index a60e57f..cc4116f 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -24,6 +24,16 @@ public static function acquire( ?GenerationContext $runtime = null, ) { $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); + } + $timeout = $timeoutMicros ?? self::runtimeTimeout($runtime); $deadline = hrtime(true) + ($timeout * 1_000); $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); @@ -33,6 +43,12 @@ public static function acquire( try { do { + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } + $wouldBlock = 0; if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { return $handle; @@ -40,12 +56,6 @@ public static function acquire( if ($wouldBlock !== 1) { throw new FileLockException($lockErrorMessage); } - - if ($runtime !== null) { - $runtime->sleepMicroseconds(1_000); - } else { - usleep(1_000); - } } while (hrtime(true) < $deadline); } catch (\Throwable $exception) { fclose($handle); From 1fceadb8b1a5e93c869147c1c4228f2965849f0e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:22:42 +0600 Subject: [PATCH 081/107] style(codec): normalize class element spacing --- src/Support/BaseEncoder.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 0a375c9..594c09b 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -135,5 +135,4 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - } From c95936baaf439c875cb38d58e459f124f0cc51f5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:24:37 +0600 Subject: [PATCH 082/107] perf(codec): simplify inline radix preparation --- src/Support/BaseEncoder.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 594c09b..b6865ad 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -57,16 +57,7 @@ public static function encodeBytes(string $bytes, int $base): string $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]; - } - + $number = array_values($unpacked); $encoded = ''; while ($number !== []) { $quotient = []; From 2612fe59a5c0c24ec4d2b48fcbeebb27f2f84167 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:26:29 +0600 Subject: [PATCH 083/107] refactor(codec): centralize byte unpack typing --- src/Support/BinaryUnpack.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Support/BinaryUnpack.php b/src/Support/BinaryUnpack.php index ec4e1e0..45b9f1d 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 */ From 7ece4345d3db14d7ccec493e3c4cc92079e04ef2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:26:33 +0600 Subject: [PATCH 084/107] refactor(codec): keep inline radix loop under complexity gate --- src/Support/BaseEncoder.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index b6865ad..f6e18f1 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -44,20 +44,14 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt public static function encodeBytes(string $bytes, int $base): string { - $byteLength = strlen($bytes); - if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new InvalidArgumentException('Byte length must be between 1 and 1024'); - } + self::assertByteLength(strlen($bytes)); 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 = array_values($unpacked); + $number = BinaryUnpack::bytes($bytes); $encoded = ''; while ($number !== []) { $quotient = []; @@ -85,6 +79,13 @@ 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); From b53b4b7580ca563e465f5d5a279275e2db8c5cd9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:30:34 +0600 Subject: [PATCH 085/107] bench(host): right-size strict warmup by concurrency --- .github/workflows/release-acceptance.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 066f179..a268033 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -197,6 +197,8 @@ jobs: CONCURRENCY: ${{ matrix.concurrency }} IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} run: | + WARMUP=$(( CONCURRENCY * 2 )) + if [ "$WARMUP" -lt 20 ]; then WARMUP=20; fi UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18080 benchmarks/release/host-router.php > .phpforge-report/baseline-server.log 2>&1 & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT @@ -207,7 +209,7 @@ jobs: done curl --fail --silent http://127.0.0.1:18080/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=500 + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup="$WARMUP" kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true @@ -220,6 +222,8 @@ jobs: CONCURRENCY: ${{ matrix.concurrency }} IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} run: | + WARMUP=$(( CONCURRENCY * 2 )) + if [ "$WARMUP" -lt 20 ]; then WARMUP=20; fi UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18081 benchmarks/release/host-router.php > .phpforge-report/candidate-server.log 2>&1 & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT @@ -230,7 +234,7 @@ jobs: done curl --fail --silent http://127.0.0.1:18081/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup=500 + php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup="$WARMUP" kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true From c3e34a1d773de414b3d29f29dbdb7264acbeab40 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:40:42 +0600 Subject: [PATCH 086/107] bench(host): interleave paired baseline and candidate trials --- benchmarks/release/HostBenchmark.php | 336 +++++++++++++++++++-------- 1 file changed, 233 insertions(+), 103 deletions(-) diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index b75b0f0..de2ad2c 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -3,9 +3,10 @@ declare(strict_types=1); $options = getopt('', [ - 'base-url:', - 'release:', - 'output:', + 'baseline-url:', + 'candidate-url:', + 'baseline-output:', + 'candidate-output:', 'route:', 'concurrency:', 'duration:', @@ -14,9 +15,10 @@ 'warmup:', ]); -$baseUrl = $options['base-url'] ?? null; -$release = $options['release'] ?? null; -$output = $options['output'] ?? null; +$baselineUrl = $options['baseline-url'] ?? null; +$candidateUrl = $options['candidate-url'] ?? null; +$baselineOutput = $options['baseline-output'] ?? null; +$candidateOutput = $options['candidate-output'] ?? null; $route = $options['route'] ?? null; $concurrency = filter_var($options['concurrency'] ?? null, FILTER_VALIDATE_INT); $duration = filter_var($options['duration'] ?? null, FILTER_VALIDATE_INT); @@ -25,12 +27,14 @@ $warmup = filter_var($options['warmup'] ?? null, FILTER_VALIDATE_INT); if ( - !is_string($baseUrl) - || $baseUrl === '' - || !is_string($release) - || $release === '' - || !is_string($output) - || $output === '' + !is_string($baselineUrl) + || $baselineUrl === '' + || !is_string($candidateUrl) + || $candidateUrl === '' + || !is_string($baselineOutput) + || $baselineOutput === '' + || !is_string($candidateOutput) + || $candidateOutput === '' || !is_string($route) || $route === '' || !is_int($concurrency) @@ -38,13 +42,14 @@ || !is_int($duration) || $duration < 1 || !is_int($repetitions) - || $repetitions < 3 + || $repetitions < 4 + || ($repetitions % 2) !== 0 || !is_int($idsPerResponse) || $idsPerResponse < 1 || !is_int($warmup) || $warmup < 1 ) { - throw new InvalidArgumentException('Invalid fixed-duration host benchmark configuration'); + throw new InvalidArgumentException('Invalid paired fixed-duration host benchmark configuration'); } if (!extension_loaded('curl')) { @@ -343,105 +348,230 @@ function uidEnvironment(string $release): array return $environment; } -$url = rtrim($baseUrl, '/') . '/' . ltrim($route, '/'); -$warmupResult = uidRunOperations($url, $concurrency, $warmup, $idsPerResponse); - -if ( - $warmupResult['failed'] !== 0 - || $warmupResult['timeouts'] !== 0 - || $warmupResult['duplicates'] !== 0 -) { - throw new RuntimeException('Host benchmark warmup produced invalid responses'); +/** + * @return array{ + * rpms:list, + * latencies:list, + * attempted:int, + * successful:int, + * failed:int, + * timeouts:int, + * duplicates:int, + * elapsed:float + * } + */ +function uidEmptyAggregate(): array +{ + return [ + 'rpms' => [], + 'latencies' => [], + 'attempted' => 0, + 'successful' => 0, + 'failed' => 0, + 'timeouts' => 0, + 'duplicates' => 0, + 'elapsed' => 0.0, + ]; } -$rpms = []; -$latencies = []; -$attempted = 0; -$successful = 0; -$failed = 0; -$timeouts = 0; -$duplicates = 0; -$elapsedSeconds = 0.0; - -for ($repetition = 0; $repetition < $repetitions; ++$repetition) { - $result = uidRunDuration($url, $concurrency, $duration, $idsPerResponse); - $rpms[] = $result['rpm']; - $attempted += $result['attempted']; - $successful += $result['successful']; - $failed += $result['failed']; - $timeouts += $result['timeouts']; - $duplicates += $result['duplicates']; - $elapsedSeconds += $result['elapsed_seconds']; - - $remaining = UID_MAX_LATENCY_SAMPLES - count($latencies); +/** + * @param array{ + * rpms:list, + * latencies:list, + * attempted:int, + * successful:int, + * failed:int, + * timeouts:int, + * duplicates:int, + * elapsed:float + * } $aggregate + * @param array{ + * attempted:int, + * successful:int, + * failed:int, + * timeouts:int, + * rpm:float, + * latencies:list, + * duplicates:int, + * elapsed_seconds:float + * } $result + */ +function uidAccumulate(array &$aggregate, array $result): void +{ + $aggregate['rpms'][] = $result['rpm']; + $aggregate['attempted'] += $result['attempted']; + $aggregate['successful'] += $result['successful']; + $aggregate['failed'] += $result['failed']; + $aggregate['timeouts'] += $result['timeouts']; + $aggregate['duplicates'] += $result['duplicates']; + $aggregate['elapsed'] += $result['elapsed_seconds']; + + $remaining = UID_MAX_LATENCY_SAMPLES - count($aggregate['latencies']); if ($remaining > 0) { - $latencies = [...$latencies, ...array_slice($result['latencies'], 0, $remaining)]; + $aggregate['latencies'] = [ + ...$aggregate['latencies'], + ...array_slice($result['latencies'], 0, $remaining), + ]; } } -sort($rpms, SORT_NUMERIC); -$medianRpm = uidPercentile($rpms, 0.50); -$spread = $medianRpm > 0 - ? ((uidPercentile($rpms, 0.75) - uidPercentile($rpms, 0.25)) / $medianRpm) * 100 - : 100.0; -$stable = $spread <= 15.0 && $failed === 0 && $duplicates === 0 && $timeouts === 0; -$name = $route . '-c' . $concurrency; - -$document = [ - 'schema_version' => 1, - 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'environment' => uidEnvironment($release), - 'workloads' => [[ - 'name' => $name, - 'type' => 'http', - 'metadata' => [ - 'route' => '/' . ltrim($route, '/'), - 'trial_duration_seconds' => $duration, - 'ids_per_response' => $idsPerResponse, - ], - 'repetitions' => $repetitions, - 'warmup_operations' => $warmup, - 'duration_seconds' => $duration * $repetitions, - 'concurrency' => $concurrency, - 'result' => [ - 'attempted_operations' => $attempted, - 'successful_operations' => $successful, - 'failed_operations' => $failed, - 'timeouts' => $timeouts, - 'duplicate_ids' => $duplicates, - 'successful_rpm' => round($medianRpm, 5), - 'error_rate' => $attempted === 0 ? 0.0 : $failed / $attempted, - 'measured_elapsed_seconds' => round($elapsedSeconds, 5), - 'latency_ms' => [ - 'minimum' => $latencies === [] ? null : round(min($latencies), 5), - 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), - 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), - 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), - 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), - 'maximum' => $latencies === [] ? null : round(max($latencies), 5), - ], - 'cpu' => [ - 'average_percent' => null, - 'peak_percent' => null, - ], - 'memory' => [ - 'average_mb' => null, - 'peak_mb' => null, - 'growth_mb' => null, +/** + * @param array{ + * rpms:list, + * latencies:list, + * attempted:int, + * successful:int, + * failed:int, + * timeouts:int, + * duplicates:int, + * elapsed:float + * } $aggregate + * @return array + */ +function uidBuildDocument( + string $release, + string $route, + int $concurrency, + int $duration, + int $repetitions, + int $idsPerResponse, + int $warmup, + array $aggregate, +): array { + sort($aggregate['rpms'], SORT_NUMERIC); + $medianRpm = uidPercentile($aggregate['rpms'], 0.50); + $spread = $medianRpm > 0 + ? ((uidPercentile($aggregate['rpms'], 0.75) - uidPercentile($aggregate['rpms'], 0.25)) / $medianRpm) * 100 + : 100.0; + $stable = $spread <= 15.0 + && $aggregate['failed'] === 0 + && $aggregate['duplicates'] === 0 + && $aggregate['timeouts'] === 0; + $latencies = $aggregate['latencies']; + + return [ + 'schema_version' => 1, + 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'environment' => uidEnvironment($release), + 'workloads' => [[ + 'name' => $route . '-c' . $concurrency, + 'type' => 'http', + 'metadata' => [ + 'route' => '/' . ltrim($route, '/'), + 'trial_duration_seconds' => $duration, + 'ids_per_response' => $idsPerResponse, + 'paired_trial_order' => 'AB/BA', ], - 'stability' => [ - 'status' => $stable ? 'stable' : 'unstable', - 'spread_percent' => round($spread, 5), + 'repetitions' => $repetitions, + 'warmup_operations' => $warmup, + 'duration_seconds' => $duration * $repetitions, + 'concurrency' => $concurrency, + 'result' => [ + 'attempted_operations' => $aggregate['attempted'], + 'successful_operations' => $aggregate['successful'], + 'failed_operations' => $aggregate['failed'], + 'timeouts' => $aggregate['timeouts'], + 'duplicate_ids' => $aggregate['duplicates'], + 'successful_rpm' => round($medianRpm, 5), + 'error_rate' => $aggregate['attempted'] === 0 + ? 0.0 + : $aggregate['failed'] / $aggregate['attempted'], + 'measured_elapsed_seconds' => round($aggregate['elapsed'], 5), + 'latency_ms' => [ + 'minimum' => $latencies === [] ? null : round(min($latencies), 5), + 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), + 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), + 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), + 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), + 'maximum' => $latencies === [] ? null : round(max($latencies), 5), + ], + 'cpu' => [ + 'average_percent' => null, + 'peak_percent' => null, + ], + 'memory' => [ + 'average_mb' => null, + 'peak_mb' => null, + 'growth_mb' => null, + ], + 'stability' => [ + 'status' => $stable ? 'stable' : 'unstable', + 'spread_percent' => round($spread, 5), + ], ], - ], - ]], + ]], + ]; +} + +$urls = [ + 'baseline' => rtrim($baselineUrl, '/') . '/' . ltrim($route, '/'), + 'candidate' => rtrim($candidateUrl, '/') . '/' . ltrim($route, '/'), +]; + +foreach ($urls as $url) { + $warmupResult = uidRunOperations($url, $concurrency, $warmup, $idsPerResponse); + if ( + $warmupResult['failed'] !== 0 + || $warmupResult['timeouts'] !== 0 + || $warmupResult['duplicates'] !== 0 + ) { + throw new RuntimeException('Host benchmark warmup produced invalid responses'); + } +} + +$aggregates = [ + 'baseline' => uidEmptyAggregate(), + 'candidate' => uidEmptyAggregate(), ]; -file_put_contents( - $output, - json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, +for ($repetition = 0; $repetition < $repetitions; ++$repetition) { + $order = ($repetition % 2) === 0 + ? ['baseline', 'candidate'] + : ['candidate', 'baseline']; + + foreach ($order as $target) { + $result = uidRunDuration( + $urls[$target], + $concurrency, + $duration, + $idsPerResponse, + ); + uidAccumulate($aggregates[$target], $result); + } +} + +$baselineDocument = uidBuildDocument( + '5.0', + $route, + $concurrency, + $duration, + $repetitions, + $idsPerResponse, + $warmup, + $aggregates['baseline'], ); +$candidateDocument = uidBuildDocument( + 'candidate', + $route, + $concurrency, + $duration, + $repetitions, + $idsPerResponse, + $warmup, + $aggregates['candidate'], +); + +foreach ([ + $baselineOutput => $baselineDocument, + $candidateOutput => $candidateDocument, +] as $path => $document) { + file_put_contents( + $path, + json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, + ); -if (!$stable) { - throw new RuntimeException('Host benchmark did not reach a stable valid state'); + $result = $document['workloads'][0]['result']; + if (($result['stability']['status'] ?? null) !== 'stable') { + throw new RuntimeException('Host benchmark did not reach a stable valid state'); + } } From 6a302ef2c72f9d0e499b4cbc6ff6c97d971819a9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 10:41:05 +0600 Subject: [PATCH 087/107] bench(host): run balanced paired trials on one runner --- .github/workflows/release-acceptance.yml | 75 ++++++++++++------------ 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index a268033..a39ccf5 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -190,7 +190,7 @@ jobs: composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative mkdir -p .phpforge-report "$RUNNER_TEMP/uid-baseline-state" "$RUNNER_TEMP/uid-candidate-state" - - name: Benchmark tag 5.0 + - name: Run interleaved paired host benchmark shell: bash env: ROUTE: ${{ matrix.route }} @@ -199,45 +199,46 @@ jobs: run: | WARMUP=$(( CONCURRENCY * 2 )) if [ "$WARMUP" -lt 20 ]; then WARMUP=20; fi - UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18080 benchmarks/release/host-router.php > .phpforge-report/baseline-server.log 2>&1 & - server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true' EXIT - for attempt in {1..50}; do - if curl --fail --silent http://127.0.0.1:18080/health >/dev/null; then break; fi - sleep 0.2 + UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" \ + UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" \ + PHP_CLI_SERVER_WORKERS=64 \ + php -S 127.0.0.1:18080 benchmarks/release/host-router.php \ + > .phpforge-report/baseline-server.log 2>&1 & + baseline_pid=$! + + UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" \ + UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" \ + PHP_CLI_SERVER_WORKERS=64 \ + php -S 127.0.0.1:18081 benchmarks/release/host-router.php \ + > .phpforge-report/candidate-server.log 2>&1 & + candidate_pid=$! + + trap 'kill "$baseline_pid" "$candidate_pid" 2>/dev/null || true' EXIT + + for port in 18080 18081; do + for attempt in {1..50}; do + if curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null; then break; fi + sleep 0.2 + done + curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null done - curl --fail --silent http://127.0.0.1:18080/health >/dev/null - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18080 --release=5.0 --output=.phpforge-report/host-baseline.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup="$WARMUP" - - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - trap - EXIT - - - name: Benchmark candidate - shell: bash - env: - ROUTE: ${{ matrix.route }} - CONCURRENCY: ${{ matrix.concurrency }} - IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} - run: | - WARMUP=$(( CONCURRENCY * 2 )) - if [ "$WARMUP" -lt 20 ]; then WARMUP=20; fi - UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" PHP_CLI_SERVER_WORKERS=64 php -S 127.0.0.1:18081 benchmarks/release/host-router.php > .phpforge-report/candidate-server.log 2>&1 & - server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true' EXIT - - for attempt in {1..50}; do - if curl --fail --silent http://127.0.0.1:18081/health >/dev/null; then break; fi - sleep 0.2 - done - curl --fail --silent http://127.0.0.1:18081/health >/dev/null - - php benchmarks/release/HostBenchmark.php --base-url=http://127.0.0.1:18081 --release=candidate --output=.phpforge-report/host-candidate.json --route="$ROUTE" --concurrency="$CONCURRENCY" --duration=60 --repetitions=3 --ids-per-response="$IDS_PER_RESPONSE" --warmup="$WARMUP" - - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + php benchmarks/release/HostBenchmark.php \ + --baseline-url=http://127.0.0.1:18080 \ + --candidate-url=http://127.0.0.1:18081 \ + --baseline-output=.phpforge-report/host-baseline.json \ + --candidate-output=.phpforge-report/host-candidate.json \ + --route="$ROUTE" \ + --concurrency="$CONCURRENCY" \ + --duration=60 \ + --repetitions=4 \ + --ids-per-response="$IDS_PER_RESPONSE" \ + --warmup="$WARMUP" + + kill "$baseline_pid" "$candidate_pid" 2>/dev/null || true + wait "$baseline_pid" 2>/dev/null || true + wait "$candidate_pid" 2>/dev/null || true trap - EXIT - name: Enforce 2 percent host budget From 5f62244d610ae77386d5d193dd86f1780eb9af8b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 12:55:10 +0600 Subject: [PATCH 088/107] :sparkles: refactor(benchmarks): optimize radix encoding and fix host benchmark stability - Optimize base encoder performance using grouped radix calculations :zap: - Fix filesystem sequence provider handling of non-canonical states :bug: - Add runtime verification and OPcache assertions to host benchmarks :stethoscope: - Add comprehensive test fixtures and unit tests for radix encoding :white_check_mark: Refs: #release-acceptance --- .github/workflows/release-acceptance.yml | 3 +- benchmarks/release/HostBenchmark.php | 47 +- benchmarks/release/host-router.php | 17 +- docs/benchmark-report.rst | 14 + docs/uid-review-and-release-plan.md | 14 +- src/Sequence/FilesystemSequenceProvider.php | 35 +- src/Support/BaseEncoder.php | 36 +- src/Support/BinaryUnpack.php | 12 + src/Support/FileLock.php | 25 +- tests/BaseEncoderTest.php | 24 + tests/Fixtures/radix-vectors.json | 632 ++++++++++++++++++++ tests/SequenceSafetyTest.php | 25 + 12 files changed, 834 insertions(+), 50 deletions(-) create mode 100644 tests/BaseEncoderTest.php create mode 100644 tests/Fixtures/radix-vectors.json diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index a39ccf5..2e7f024 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -180,6 +180,7 @@ jobs: php-version: "8.4" tools: composer:v2 extensions: ctype,curl + ini-values: opcache.enable_cli=1,opcache.jit=0 coverage: none - name: Install benchmark tooling and targets @@ -198,7 +199,7 @@ jobs: IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} run: | WARMUP=$(( CONCURRENCY * 2 )) - if [ "$WARMUP" -lt 20 ]; then WARMUP=20; fi + if [ "$WARMUP" -lt 130 ]; then WARMUP=130; fi UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" \ UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" \ diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index de2ad2c..8ab68ce 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -311,9 +311,10 @@ function uidRunDuration( } /** + * @param array $serverRuntime * @return array */ -function uidEnvironment(string $release): array +function uidEnvironment(string $release, array $serverRuntime): array { $cpuModel = 'unknown'; $cpuInfo = is_readable('/proc/cpuinfo') ? file_get_contents('/proc/cpuinfo') : false; @@ -336,6 +337,7 @@ function uidEnvironment(string $release): array 'xdebug' => extension_loaded('xdebug'), 'extensions' => $extensions, 'runner' => (string) (getenv('RUNNER_NAME') ?: 'github-actions'), + 'server_runtime' => $serverRuntime, ]; $fingerprintSource = $environment; @@ -426,6 +428,7 @@ function uidAccumulate(array &$aggregate, array $result): void * duplicates:int, * elapsed:float * } $aggregate + * @param array $serverRuntime * @return array */ function uidBuildDocument( @@ -437,9 +440,13 @@ function uidBuildDocument( int $idsPerResponse, int $warmup, array $aggregate, + array $serverRuntime, ): array { - sort($aggregate['rpms'], SORT_NUMERIC); - $medianRpm = uidPercentile($aggregate['rpms'], 0.50); + // Balanced AB/BA trials always have an even sample count. + $sortedRpms = $aggregate['rpms']; + sort($sortedRpms, SORT_NUMERIC); + $middle = intdiv(count($sortedRpms), 2); + $medianRpm = ($sortedRpms[$middle - 1] + $sortedRpms[$middle]) / 2; $spread = $medianRpm > 0 ? ((uidPercentile($aggregate['rpms'], 0.75) - uidPercentile($aggregate['rpms'], 0.25)) / $medianRpm) * 100 : 100.0; @@ -452,7 +459,7 @@ function uidBuildDocument( return [ 'schema_version' => 1, 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'environment' => uidEnvironment($release), + 'environment' => uidEnvironment($release, $serverRuntime), 'workloads' => [[ 'name' => $route . '-c' . $concurrency, 'type' => 'http', @@ -467,6 +474,7 @@ function uidBuildDocument( 'duration_seconds' => $duration * $repetitions, 'concurrency' => $concurrency, 'result' => [ + 'trial_successful_rpm' => $aggregate['rpms'], 'attempted_operations' => $aggregate['attempted'], 'successful_operations' => $aggregate['successful'], 'failed_operations' => $aggregate['failed'], @@ -508,6 +516,33 @@ function uidBuildDocument( 'candidate' => rtrim($candidateUrl, '/') . '/' . ltrim($route, '/'), ]; +/** @return array */ +function uidServerRuntime(string $url): array +{ + $handle = uidCreateHandle(rtrim($url, '/') . '/health'); + $body = curl_exec($handle); + $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + if (!is_string($body) || $httpCode !== 200) { + throw new RuntimeException('Unable to read benchmark server runtime'); + } + + $health = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $runtime = is_array($health) ? ($health['runtime'] ?? null) : null; + if (!is_array($runtime)) { + throw new RuntimeException('Benchmark server runtime is missing'); + } + + return $runtime; +} + +$serverRuntime = uidServerRuntime($baselineUrl); +if ($serverRuntime !== uidServerRuntime($candidateUrl)) { + throw new RuntimeException('Benchmark server runtimes do not match'); +} +if (($serverRuntime['opcache'] ?? false) !== true) { + throw new RuntimeException('Warm host benchmark requires OPcache on both servers'); +} + foreach ($urls as $url) { $warmupResult = uidRunOperations($url, $concurrency, $warmup, $idsPerResponse); if ( @@ -549,6 +584,7 @@ function uidBuildDocument( $idsPerResponse, $warmup, $aggregates['baseline'], + $serverRuntime, ); $candidateDocument = uidBuildDocument( 'candidate', @@ -559,6 +595,7 @@ function uidBuildDocument( $idsPerResponse, $warmup, $aggregates['candidate'], + $serverRuntime, ); foreach ([ @@ -569,7 +606,9 @@ function uidBuildDocument( $path, json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, ); +} +foreach ([$baselineDocument, $candidateDocument] as $document) { $result = $document['workloads'][0]['result']; if (($result['stability']['status'] ?? null) !== 'stable') { throw new RuntimeException('Host benchmark did not reach a stable valid state'); diff --git a/benchmarks/release/host-router.php b/benchmarks/release/host-router.php index bac5707..1dce164 100644 --- a/benchmarks/release/host-router.php +++ b/benchmarks/release/host-router.php @@ -25,7 +25,22 @@ try { if ($path === '/health') { - file_put_contents('php://output', json_encode(['ok' => true], JSON_THROW_ON_ERROR)); + $opcache = function_exists('opcache_get_status') ? opcache_get_status(false) : false; + $extensions = get_loaded_extensions(); + sort($extensions, SORT_STRING); + file_put_contents('php://output', json_encode([ + 'ok' => true, + 'runtime' => [ + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'memory_limit' => (string) ini_get('memory_limit'), + 'extensions' => $extensions, + 'opcache' => is_array($opcache) && ($opcache['opcache_enabled'] ?? false), + 'opcache_validate_timestamps' => (string) ini_get('opcache.validate_timestamps'), + 'opcache_optimization_level' => (string) ini_get('opcache.optimization_level'), + 'jit' => (string) ini_get('opcache.jit'), + ], + ], JSON_THROW_ON_ERROR)); return; } diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst index 541ce19..59b4f57 100644 --- a/docs/benchmark-report.rst +++ b/docs/benchmark-report.rst @@ -62,6 +62,20 @@ 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. +Release Host Comparison +----------------------- + +The release acceptance workflow compares production-only, authoritative +autoloaders against tag ``5.0`` on one runner. Both HTTP servers enable OPcache +and disable JIT. Warm-up covers at least twice the configured worker count before +four balanced AB/BA trials of 60 seconds at each route and concurrency level. +The benchmark records each server's actual cache state and rejects mismatched +runtimes or disabled OPcache. Cold component profiles remain separate diagnostics. + +The host gate retains the 2% successful-RPM regression limit and requires stable +trials without response errors, timeouts or duplicate IDs within responses. Both +result files are retained even when a stability check fails. + 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. diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index dcebb12..bedfbe1 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -435,11 +435,21 @@ do not assert an improvement solely from historical microsecond timings. ## Performance and release gates -Current ordinary hosted evidence: Security & Standards run `37563316186` on -`6b89b29972f43f3e1943d086339e5b18ee711a7d` passed clean install, component +Current committed hosted evidence: Security & Standards run `37572646512` on +`6a302ef2c72f9d0e499b4cbc6ff6c97d971819a9` passed clean install, component benchmarks on PHP 8.4/8.5, analysis on PHP 8.4/8.5, and all four stable/lowest QA lanes. This is implementation QA evidence, not host-RPM or soak certification. +Release Acceptance run `37572646018` on that revision passed diagnostics and the +persistent-worker soak, but six host lanes exceeded the 2% RPM budget: CUID2 batch +at concurrency 1, 5, 20 and 50, and Snowflake contention at concurrency 1 and 5. +Working-tree remediation adds grouped radix encoding with independent legacy +vectors, omits unused default-provider reservation bookkeeping, reduces repeated +lock ownership lookups, and corrects warm host cache +configuration and median/report handling. All final hosted gates must run again +on the revision containing these changes; earlier soak or QA results do not +certify the modified candidate. + - [ ] Measure corrected code against tag `5.0` with matching runtimes, dependencies, hardware and deployment configuration. Separate pure-generator, filesystem, reservation, PSR-16 and optional Runwire-bound workloads. diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index c655c6d..1d8c046 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -53,11 +53,12 @@ public function __construct( public function next(string $type, int $machineId, int $timestamp): int { $fileLocation = $this->sequenceFileLocation($type, $machineId); - $this->resetAfterFork(); - - $reserved = $this->takeReservedAllocation($fileLocation, $timestamp); - if ($reserved !== null) { - return $reserved; + if ($this->reservationSize > 1) { + $this->resetAfterFork(); + $reserved = $this->takeReservedAllocation($fileLocation, $timestamp); + if ($reserved !== null) { + return $reserved; + } } $handle = FileLock::acquire( @@ -76,13 +77,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 */ @@ -129,22 +123,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'); } diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index f6e18f1..384839b 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -19,6 +19,15 @@ final class BaseEncoder 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], + ]; + public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string { if ($encoded === '') { @@ -51,23 +60,27 @@ public static function encodeBytes(string $bytes, int $base): string } $alphabet = self::alphabet($base); - $number = BinaryUnpack::bytes($bytes); + [$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 $byte) { - $value = ($remainder << 8) | $byte; - $digit = intdiv($value, $base); - $remainder = $value % $base; + 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; } } - $encoded = $alphabet[$remainder] . $encoded; + $chunk = self::encodeGroup($remainder, $base, $alphabet); + $encoded = ($quotient === [] ? $chunk : str_pad($chunk, $width, $alphabet[0], STR_PAD_LEFT)) . $encoded; $number = $quotient; } @@ -127,4 +140,15 @@ private static function decodeRadix(string $encoded, int $base, int $bytesLength return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } + + private static function encodeGroup(int $value, int $base, string $alphabet): string + { + $encoded = ''; + do { + $encoded = $alphabet[$value % $base] . $encoded; + $value = intdiv($value, $base); + } while ($value > 0); + + return $encoded; + } } diff --git a/src/Support/BinaryUnpack.php b/src/Support/BinaryUnpack.php index 45b9f1d..d2e3650 100644 --- a/src/Support/BinaryUnpack.php +++ b/src/Support/BinaryUnpack.php @@ -42,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/FileLock.php b/src/Support/FileLock.php index cc4116f..aed298c 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -72,13 +72,13 @@ public static function acquire( * @param array $metadata * @throws FileLockException */ - private static function assertSafeMetadata(array $metadata, string $errorMessage): void + private static function assertSafeMetadata(array $metadata, string $errorMessage, ?int $ownerId): void { if (($metadata['mode'] & 0170000) !== 0100000) { throw new FileLockException($errorMessage); } - if (function_exists('posix_geteuid') && $metadata['uid'] !== posix_geteuid()) { + if ($ownerId !== null && $metadata['uid'] !== $ownerId) { throw new FileLockException($errorMessage); } } @@ -100,6 +100,7 @@ private static function assertSameFile(array $left, array $right, string $errorM */ 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); @@ -107,7 +108,7 @@ static function (int $severity, string $message, string $file, int $line): never ); try { - return self::openVerifiedWithHandler($path, $errorMessage); + return self::openVerifiedWithHandler($path, $errorMessage, $ownerId); } catch (ErrorException $exception) { throw new FileLockException($errorMessage, 0, $exception); } finally { @@ -120,7 +121,7 @@ static function (int $severity, string $message, string $file, int $line): never * @throws FileLockException * @throws ErrorException */ - private static function openVerifiedWithHandler(string $path, string $errorMessage) + private static function openVerifiedWithHandler(string $path, string $errorMessage, ?int $ownerId) { try { $before = lstat($path); @@ -129,11 +130,11 @@ private static function openVerifiedWithHandler(string $path, string $errorMessa } if ($before !== false) { - self::assertSafeMetadata($before, $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); + return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId); } try { @@ -141,18 +142,18 @@ private static function openVerifiedWithHandler(string $path, string $errorMessa } catch (ErrorException) { $before = lstat($path); $before !== false || throw new FileLockException($errorMessage); - self::assertSafeMetadata($before, $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); + 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); + return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId); } private static function runtimeTimeout(?GenerationContext $runtime): int @@ -167,7 +168,7 @@ private static function runtimeTimeout(?GenerationContext $runtime): int * @param array|null $before * @return resource */ - private static function verifyHandle(string $path, $handle, ?array $before, string $errorMessage) + private static function verifyHandle(string $path, $handle, ?array $before, string $errorMessage, ?int $ownerId) { try { $after = fstat($handle); @@ -176,8 +177,8 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri throw new FileLockException($errorMessage); } - self::assertSafeMetadata($after, $errorMessage); - self::assertSafeMetadata($pathState, $errorMessage); + self::assertSafeMetadata($after, $errorMessage, $ownerId); + self::assertSafeMetadata($pathState, $errorMessage, $ownerId); self::assertSameFile($after, $pathState, $errorMessage); if ($before !== null) { self::assertSameFile($before, $after, $errorMessage); 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/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/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php index 6c583c4..7fdaf5f 100644 --- a/tests/SequenceSafetyTest.php +++ b/tests/SequenceSafetyTest.php @@ -55,3 +55,28 @@ } } }); + +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,', +]); From d3858dc537d08710502f3222ab9f1155bd7def30 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 13:33:29 +0600 Subject: [PATCH 089/107] :sparkles: refactor(sequence): improve runtime context forwarding and lock validation - Forward GenerationContext to built-in filesystem and PSR-16 sequence providers :zap: - Validate lock identity, timestamps, and request cancellation state :lock: - Add comprehensive regression and boundary tests for release readiness :white_check_mark: --- benchmarks/release/HostBenchmark.php | 20 +- docs/framework-integration.rst | 6 + docs/migration-6.0.rst | 11 ++ docs/tbsl.rst | 6 + docs/uid-review-and-release-plan.md | 82 +++++++- src/Configuration/RandflakeConfig.php | 4 + src/Randflake.php | 12 +- src/Runtime/GenerationContext.php | 12 +- src/Sequence/FilesystemSequenceProvider.php | 12 +- .../PsrSimpleCacheSequenceProvider.php | 31 +-- src/Snowflake.php | 2 +- src/Sonyflake.php | 9 +- src/Support/FileLock.php | 37 ++-- src/Support/GetSequence.php | 5 + src/TBSL.php | 34 +++- src/ULID.php | 8 + src/UUID.php | 13 +- tests/HostBenchmarkAcceptanceTest.php | 26 +++ tests/ReleaseBoundaryTest.php | 182 ++++++++++++++++++ tests/RuntimeIntegrationTest.php | 58 ++++++ tests/SequenceProviderTest.php | 34 ++++ tests/SequenceSafetyTest.php | 52 +++++ 22 files changed, 604 insertions(+), 52 deletions(-) create mode 100644 tests/HostBenchmarkAcceptanceTest.php create mode 100644 tests/ReleaseBoundaryTest.php diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php index 8ab68ce..c587920 100644 --- a/benchmarks/release/HostBenchmark.php +++ b/benchmarks/release/HostBenchmark.php @@ -2,6 +2,10 @@ declare(strict_types=1); +if (realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) !== __FILE__) { + return; +} + $options = getopt('', [ 'baseline-url:', 'candidate-url:', @@ -96,6 +100,17 @@ function uidCreateHandle(string $url): CurlHandle return $handle; } +function uidValidId(string $id, string $route): bool +{ + if ($route === '/snowflake-contended') { + return preg_match('/\A(?:0|[1-9][0-9]{0,18})\z/', $id) === 1 + && (strlen($id) < 19 || strcmp($id, (string) PHP_INT_MAX) <= 0); + } + + return in_array($route, ['/cuid2-one', '/cuid2-batch'], true) + && preg_match('/\A[a-z][a-z0-9]{23}\z/', $id) === 1; +} + /** * @param array{result:int,handle:CurlHandle} $info * @return array{successful:bool,timeout:bool,latency:float,duplicates:int} @@ -117,9 +132,10 @@ function uidInspectCompletion(array $info, int $idsPerResponse): array $duplicates = 0; $responseIds = []; + $route = (string) parse_url((string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL), PHP_URL_PATH); foreach ($ids as $id) { - if (!is_string($id) || $id === '') { + if (!is_string($id) || !uidValidId($id, $route)) { $successful = false; continue; @@ -133,7 +149,7 @@ function uidInspectCompletion(array $info, int $idsPerResponse): array } return [ - 'successful' => $successful, + 'successful' => $successful && $duplicates === 0, 'timeout' => $info['result'] === CURLE_OPERATION_TIMEDOUT, 'latency' => $latency, 'duplicates' => $duplicates, diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index 2a9eae7..c3dca5d 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -66,6 +66,12 @@ the request. UID verifies that the scope is active and that the request belongs the supplied runtime; it does not depend on Runwire private internals. When a scope is supplied, UID uses cooperative sleeps for lock and rollover waits. +The generator forwards its GenerationContext to built-in filesystem and PSR-16 +providers for that allocation. A shared provider does not retain the operation's +request binding; subsequent requests can supply their own context through configs. +Custom providers remain responsible for their own synchronization and cooperative +I/O. A context supplied when constructing a provider remains a scoped default; +do not keep a request-bound provider after that request completes. Without Runwire, the same bounded policies use native synchronous waits. Missing Runwire is a normal fallback condition; cancellation, an expired deadline, stale process identity, completed requests, corrupt state and authoritative-store diff --git a/docs/migration-6.0.rst b/docs/migration-6.0.rst index bd013ec..f29bd49 100644 --- a/docs/migration-6.0.rst +++ b/docs/migration-6.0.rst @@ -76,6 +76,17 @@ through GenerationContext; UID never owns the host lifecycle. Static sequence-provider selectors remain process/worker scoped. Do not use them as request-local configuration in concurrent persistent workers. +Built-in filesystem and PSR-16 providers receive the config's context for each +allocation without storing it. Cancellation stops both fresh and reserved +allocation before mutation. Provider defaults, operation limits and host deadlines +combine using the earliest applicable wait limit. + +Injected generation clocks must produce non-negative timestamps that fit in +64-bit integer microseconds. TBSL rollback state is scoped to provider and machine, +so separate ID domains can use independent clocks. Existing TBSL bytes remain +unchanged; parsing also preserves eleven-digit Unix seconds within its 60-bit field. +ULID and UUIDv7 rollover waits fail after one second or at timestamp exhaustion. + Security Boundary ----------------- diff --git a/docs/tbsl.rst b/docs/tbsl.rst index 7b2d8a2..3fb8c02 100644 --- a/docs/tbsl.rst +++ b/docs/tbsl.rst @@ -33,6 +33,12 @@ Use ``Infocyph\\UID\\Configuration\\TBSLConfig`` for: - toggling ``sequenced`` mode - custom sequence provider - clock-backward policy +- a GenerationContext for an injected clock, bounded waits and optional Runwire + +Rollback state belongs to the provider and machine domain. Reuse the same +authoritative provider for writers in one domain; separate providers do not share +clock history. The timestamp/machine portion occupies 60 bits, and generation +rejects timestamps outside that field before normal allocation. .. code-block:: php diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index bedfbe1..5cde49d 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -1,9 +1,88 @@ +--- +orphan: true +--- + # UID 6.0 full improvement and release plan Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3cef0fa`. Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. Status: sections A–F implemented and regression-covered; section G, host performance, soak, and exact-final release acceptance remain open. +## 2026-10-07 cross-check + +Rechecked committed revision `5f62244d610ae77386d5d193dd86f1780eb9af8b` +and the complete implementation against the acceptance requirements below. +The plan remains because the release gates are not complete. + +Additional working-tree corrections and regressions: + +- Forward each generator config's `GenerationContext` to built-in filesystem and + PSR-16 allocation without storing a request binding on the shared provider. + Contended-lock tests exercise intermediary forwarding, other-task progress and + host cancellation through this config-only path. +- Check cancellation/completion before fresh and reserved provider allocation, + after a cache read and immediately before mutation; preserve terminal binding + failures through synchronizer/error handling. +- Refresh lock pathname metadata rather than trusting PHP's cached `lstat()` + result. A process replacement fixture verifies rejection of an externally + replaced path while leaving its target untouched. +- Associate TBSL rollback history weakly with the actual provider and machine, + preserving independent clocks. Validate its 60-bit timestamp before normal + allocation and correctly parse eleven-digit Unix seconds. +- Reject Sonyflake epochs in the future even within one 10 ms tick, and reject + extreme epochs before overflowing integer division. +- Keep clock and wait calculations in the supported integer domain, reject + overflowing inclusive-to-exclusive Randflake leases, recheck live Randflake + provider state after reentrant/suspending allocation, and reject times before + Randflake's epoch before allocation. +- Bound implicit ULID/UUIDv7 rollover waits and fail promptly at timestamp + exhaustion; require exact UUID node width including end of input. +- Validate host response IDs against the workload format and exclude duplicate + responses from successful throughput. Protect even-sample median/trial metadata + behavior with regression coverage. + +Hosted Security & Standards run `37584251034` passed on `5f62244`. +Hosted Release Acceptance run `37584250265` passed diagnostics and the existing +five-minute soak, but failed the following required host gates: + +| Workload | Baseline RPM | Candidate RPM | Failure | +| --- | ---: | ---: | --- | +| Snowflake contention, concurrency 1 | 136025.70070 | 131210.32508 | 3.54% regression; 2% budget | +| Snowflake contention, concurrency 50 | 556309.78158 | 539240.87060 | 3.07% regression; 2% budget | +| CUID2 single ID, concurrency 50 | 213626.22967 | 387476.98209 | Candidate spread 16.47568%; 15% stability ceiling | + +All three downloaded pairs report zero failed responses, timeouts and +within-response duplicates. CUID2's gain does not excuse its unstable trials. +These results certify neither the new working-tree corrections nor a 6.0 release. + +The full plan also requires acceptance coverage that the current harness has not +yet supplied: host CPU/peak and steady RSS fields are null; worker readiness is +inferred from request counts rather than verified per worker; HTTP duplicate +detection is within responses rather than across the measured workload; queue, +lock-wait and predefined resource/latency ceilings are absent. The soak exercises +in-memory generation and cancellation, but not contention, released provider +domains or worker replacement. The Runwire profile compares CPU generation only +and does not measure its intended scheduling benefit under contention. +Keep these gates open; do not remove this plan or tag 6.0 until final-revision +evidence closes them without weakening correctness, security or budgets. + +Local verification of the corrected source on PHP 8.5.4: PHPForge processors, +the full detailed suite and the final release guard passed. The final guard ran +227 tests / 4,074 assertions and reported zero dependency advisories; the existing +transitive development-only `doctrine/annotations` abandonment remains a warning. +The strict Sphinx build (`-n -W --keep-going`) passed. A clean authoritative +`--no-dev` installation executed native generators, both format modes, value +metadata and codecs with Runwire, PSR-20 and PSR-16 absent. The pathname replacement +regression was independently run with the committed old opener and failed there. + +Short paired PHP 8.5.4 diagnostics (four trials of five seconds, not the required +sustained acceptance) on the corrected production source retained zero response +errors, timeouts and within-response duplicates. Snowflake concurrency 1 measured +384004.51513 → 374224.65428 RPM (2.55% regression); concurrency 50 measured +652734.08177 → 641236.76811 RPM (1.76% regression). Both pairs were stable under +the existing spread rule. These short diagnostics leave the required sustained +Snowflake gate open and do not replace exact-final PHP 8.4/8.5 hosted evidence. + This plan follows `vendor/infocyph/phpforge/resources/engineering-principles.md`: correctness and security precede performance; preserve public contracts and named arguments; distinguish required changes from optional features; keep dependencies @@ -435,7 +514,8 @@ do not assert an improvement solely from historical microsecond timings. ## Performance and release gates -Current committed hosted evidence: Security & Standards run `37572646512` on +Previous committed hosted evidence, superseded by the cross-check above: +Security & Standards run `37572646512` on `6a302ef2c72f9d0e499b4cbc6ff6c97d971819a9` passed clean install, component benchmarks on PHP 8.4/8.5, analysis on PHP 8.4/8.5, and all four stable/lowest QA lanes. This is implementation QA evidence, not host-RPM or soak certification. diff --git a/src/Configuration/RandflakeConfig.php b/src/Configuration/RandflakeConfig.php index 6f32f5f..5148c58 100644 --- a/src/Configuration/RandflakeConfig.php +++ b/src/Configuration/RandflakeConfig.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Configuration; use Infocyph\UID\Enums\RandflakeFormat; +use Infocyph\UID\Exceptions\RandflakeException; use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; @@ -27,6 +28,9 @@ public function resolveLeaseEndExclusive(): int if ($this->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/Randflake.php b/src/Randflake.php index 374b157..c3587bf 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -373,7 +373,7 @@ private static function allocateSequence( $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream'; try { - return [$now, self::sequence($now, $nodeId, $type, $provider)]; + return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)]; } catch (SequenceTimestampException $exception) { $now = self::nowSeconds($runtime); self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive); @@ -385,7 +385,7 @@ private static function allocateSequence( ); } - return [$now, self::sequence($now, $nodeId, $type, $provider)]; + return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)]; } } @@ -400,7 +400,7 @@ private static function assertGenerationTime( throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); } - if ($now > self::MAX_TIMESTAMP) { + if ($now < self::EPOCH_OFFSET || $now > self::MAX_TIMESTAMP) { throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); } } @@ -464,7 +464,8 @@ private static function generateInternal( $leaseEndExclusive, $runtime, ); - $sequence = self::normalizeAllocation($allocation, $last, $now); + // 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 self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format); @@ -522,6 +523,9 @@ private static function normalizeAllocation(int $allocation, ?array $last, int $ } $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'); } diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php index 650b4b8..4b23912 100644 --- a/src/Runtime/GenerationContext.php +++ b/src/Runtime/GenerationContext.php @@ -34,7 +34,13 @@ public function nowMicroseconds(): int return (int) floor(microtime(true) * 1_000_000); } - return (int) $this->clock->now()->format('Uu'); + $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 @@ -62,7 +68,9 @@ public function sleepMicroseconds(int $microseconds): void public function waitDeadlineNanoseconds(): int { - $deadline = hrtime(true) + ($this->waitTimeoutMicros * 1_000); + $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/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 1d8c046..e7b523e 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -50,8 +50,11 @@ 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); if ($this->reservationSize > 1) { $this->resetAfterFork(); @@ -66,11 +69,11 @@ public function next(string $type, int $machineId, int $timestamp): int $this->lockTimeoutMicros, 'Failed to open sequence file: ' . $fileLocation, 'Unable to acquire sequence lock: ' . $fileLocation, - $this->runtime, + $runtime, ); try { - return $this->allocateLocked($handle, $fileLocation, $timestamp); + return $this->allocateLocked($handle, $fileLocation, $timestamp, $runtime); } finally { flock($handle, LOCK_UN); fclose($handle); @@ -80,7 +83,7 @@ public function next(string $type, int $machineId, int $timestamp): int /** * @param resource $handle */ - private function allocateLocked($handle, string $fileLocation, int $timestamp): int + private function allocateLocked($handle, string $fileLocation, int $timestamp, ?GenerationContext $runtime): int { [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); if ($lastTimestamp > $timestamp) { @@ -97,6 +100,7 @@ private function allocateLocked($handle, string $fileLocation, int $timestamp): } $reservedEnd = $allocation + $reservationOffset; + $runtime?->assertActive(); $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength); $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd); diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index f84d943..918e6ec 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -50,21 +50,24 @@ 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) { - return $this->nextSynchronized($this->synchronizer, $key, $timestamp); + return $this->nextSynchronized($this->synchronizer, $key, $timestamp, $runtime); } - $lock = $this->acquireLock($key); + $lock = $this->acquireLock($key, $runtime); try { - return $this->nextSafely($key, $timestamp); + return $this->nextSafely($key, $timestamp, $runtime); } finally { flock($lock, LOCK_UN); fclose($lock); @@ -117,7 +120,7 @@ private static function nextSequence(?array $state, int $timestamp, string $key) * @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'; @@ -126,7 +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, - $this->runtime, + $runtime, ); } @@ -144,8 +147,9 @@ 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 { + $runtime?->assertActive(); $state = $this->normalizeState($this->cache->get($key), $key); $observed = $this->observedState[$key] ?? null; if ($state === null && $observed !== null) { @@ -155,6 +159,7 @@ private function nextFromCacheState(string $key, int $timestamp): int 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); } @@ -164,27 +169,31 @@ private function nextFromCacheState(string $key, int $timestamp): int return $sequence; } - private function nextSafely(string $key, int $timestamp): int + private function nextSafely(string $key, int $timestamp, ?GenerationContext $runtime): int { try { - return $this->nextFromCacheState($key, $timestamp); + 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): int + private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp, ?GenerationContext $runtime): int { try { $sequence = $synchronizer( $key, - fn(): int => $this->nextFromCacheState($key, $timestamp), + fn(): int => $this->nextFromCacheState($key, $timestamp, $runtime), ); } catch (FileLockException $exception) { throw $exception; } catch (Throwable $exception) { + $runtime?->assertActive(); + throw $this->storageFailure($key, $exception); } diff --git a/src/Snowflake.php b/src/Snowflake.php index 27106df..7f17c97 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -347,7 +347,7 @@ private static function nextSequenceAtValidTimestamp( ): 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( diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 9ff81e3..b269cef 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -202,7 +202,7 @@ private static function allocateSequence( while (true) { try { - $allocation = self::sequence($elapsedTime, $machineId, $sequenceType, $provider); + $allocation = self::sequence($elapsedTime, $machineId, $sequenceType, $provider, $runtime); } catch (SequenceTimestampException $exception) { if ($policy === ClockBackwardPolicy::THROW) { throw new SonyflakeException( @@ -263,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); } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index aed298c..6df9b43 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -23,6 +23,7 @@ public static function acquire( string $lockErrorMessage, ?GenerationContext $runtime = null, ) { + $runtime?->assertActive(); $handle = self::openVerified($path, $openErrorMessage); $wouldBlock = 0; if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { @@ -34,15 +35,10 @@ public static function acquire( throw new FileLockException($lockErrorMessage); } - $timeout = $timeoutMicros ?? self::runtimeTimeout($runtime); - $deadline = hrtime(true) + ($timeout * 1_000); - $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); - if ($runtimeDeadline !== null) { - $deadline = min($deadline, $runtimeDeadline); - } - try { - do { + $deadline = self::lockDeadline($timeoutMicros, $runtime); + + while (hrtime(true) < $deadline) { if ($runtime !== null) { $runtime->sleepMicroseconds(1_000); } else { @@ -56,7 +52,7 @@ public static function acquire( if ($wouldBlock !== 1) { throw new FileLockException($lockErrorMessage); } - } while (hrtime(true) < $deadline); + } } catch (\Throwable $exception) { fclose($handle); @@ -94,6 +90,19 @@ private static function assertSameFile(array $left, array $right, string $errorM } } + 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 @@ -123,6 +132,8 @@ static function (int $severity, string $message, string $file, int $line): never */ private static function openVerifiedWithHandler(string $path, string $errorMessage, ?int $ownerId) { + clearstatcache(true, $path); + try { $before = lstat($path); } catch (ErrorException) { @@ -156,13 +167,6 @@ private static function openVerifiedWithHandler(string $path, string $errorMessa return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId); } - private static function runtimeTimeout(?GenerationContext $runtime): int - { - return $runtime instanceof GenerationContext - ? $runtime->waitTimeoutMicros - : self::DEFAULT_TIMEOUT_MICROS; - } - /** * @param resource $handle * @param array|null $before @@ -172,6 +176,7 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri { try { $after = fstat($handle); + clearstatcache(true, $path); $pathState = lstat($path); if ($after === false || $pathState === false) { throw new FileLockException($errorMessage); diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index 8e4e758..504f49f 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -102,9 +102,14 @@ private static function sequence( int $machineId, string $type, ?SequenceProviderInterface $provider = null, + ?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/TBSL.php b/src/TBSL.php index 4f791f1..cb8800e 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -11,6 +11,7 @@ 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; @@ -21,7 +22,8 @@ final class TBSL private const int WAIT_TIMEOUT_MICROS = 1_000_000; - private static int $lastTimeSequence = 0; + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastTimeByProvider = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into canonical TBSL. @@ -111,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, ]; } @@ -156,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 */ @@ -168,15 +177,21 @@ private static function generateInternal( ): string { self::assertMachineId($machineId); + $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, $runtime); + $timeSequence = self::waitUntilNextTimeSequence($lastTime, $runtime); } + self::assertTimestamp($timeSequence, $machineId); [$timeSequence, $tail] = self::resolveTail( $machineId, $sequenced, @@ -185,9 +200,10 @@ private static function generateInternal( $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'); @@ -228,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( diff --git a/src/ULID.php b/src/ULID.php index 3a62b43..a5f027a 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -107,6 +107,7 @@ public static function generate( } $time = self::waitForNextMillisecond(self::$lastGenTime); + self::assertTimestamp($time); self::$lastGenTime = $time; $timeChars = self::encodeTime($time); self::resetRandomState(); @@ -319,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 784f7da..4fef6e7 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -658,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; } @@ -693,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'); } diff --git a/tests/HostBenchmarkAcceptanceTest.php b/tests/HostBenchmarkAcceptanceTest.php new file mode 100644 index 0000000..9f279c5 --- /dev/null +++ b/tests/HostBenchmarkAcceptanceTest.php @@ -0,0 +1,26 @@ +toBeTrue() + ->and(uidValidId('x', '/cuid2-one'))->toBeFalse() + ->and(uidValidId(str_repeat('0', 24), '/cuid2-batch'))->toBeFalse() + ->and(uidValidId('a' . str_repeat('0', 23) . "\n", '/cuid2-one'))->toBeFalse() + ->and(uidValidId((string) PHP_INT_MAX, '/snowflake-contended'))->toBeTrue() + ->and(uidValidId('9223372036854775808', '/snowflake-contended'))->toBeFalse() + ->and(uidValidId('-1', '/snowflake-contended'))->toBeFalse() + ->and(uidValidId('1', '/unrecognized'))->toBeFalse(); +}); + +test('host report computes an even-sample median and retains trial order outside workload metadata', function (): void { + $aggregate = uidEmptyAggregate(); + $aggregate['rpms'] = [400.0, 100.0, 300.0, 200.0]; + $document = uidBuildDocument('candidate', 'cuid2-one', 1, 60, 4, 1, 130, $aggregate, []); + $workload = $document['workloads'][0]; + expect($workload['result']['successful_rpm'])->toBe(250.0) + ->and($workload['result']['trial_successful_rpm'])->toBe([400.0, 100.0, 300.0, 200.0]) + ->and(array_key_exists('trial_successful_rpm', $workload['metadata']))->toBeFalse(); +}); 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 index 7dbf945..07b3975 100644 --- a/tests/RuntimeIntegrationTest.php +++ b/tests/RuntimeIntegrationTest.php @@ -194,3 +194,61 @@ function (CoroutineScope $scope) use (&$capturedScope): void { ->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]); diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php index eb3e528..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; diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php index 7fdaf5f..5127bd5 100644 --- a/tests/SequenceSafetyTest.php +++ b/tests/SequenceSafetyTest.php @@ -4,6 +4,58 @@ use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Sequence\FilesystemSequenceProvider; +use Infocyph\UID\Support\FileLock; + +test('lock identity verification refreshes cached pathname metadata after external replacement', function (): void { + expect(function_exists('pcntl_fork'))->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') { From 7721003b7306ff16f6f938af071e79ab81b05a0c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 14:57:33 +0600 Subject: [PATCH 090/107] :sparkles: refactor(sequence): optimize file locking and optimize sequence reservation logic - Update filesystem sequence provider and support files with optimizations :zap: - Refactor file lock verification and status cache handling :recycle: - Add tests for reservation sizes and cached parent resolution :white_check_mark: - Update documentation and release planning notes :memo: Refs: #5.0 --- docs/uid-review-and-release-plan.md | 74 +++++++++++++++++++++ src/Sequence/FilesystemSequenceProvider.php | 8 +-- src/Support/FileLock.php | 71 +++++++++----------- src/Support/GetSequence.php | 4 +- src/TBSL.php | 2 +- tests/FilesystemSequenceV5Test.php | 6 +- tests/SequenceSafetyTest.php | 45 +++++++++++++ 7 files changed, 158 insertions(+), 52 deletions(-) diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index 5cde49d..07003f6 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -8,6 +8,80 @@ Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3c Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. Status: sections A–F implemented and regression-covered; section G, host performance, soak, and exact-final release acceptance remain open. +## Latest gate investigation (2026-10-07) + +Rechecked exact committed revision +`d3858dc537d08710502f3222ab9f1155bd7def30`. Security & Standards run +`37588152700` passed. Release Acceptance run `37588152012` passed diagnostics, +the five-minute soak and eleven of twelve host-performance lanes. The single +remaining failed job is `host-performance (snowflake-contended, 5, 1)`: + +| Metric | Tag 5.0 | Candidate | +| --- | ---: | ---: | +| Median successful RPM | 259926.06731 | 252941.12415 | +| Trial spread | 0.52804% | 0.87610% | +| p99 latency | 3.489 ms | 3.376 ms | + +Both sides were stable and recorded zero failed responses, timeouts and +within-response duplicates. The 2.69% RPM regression exceeds the unchanged 2% +budget. This supersedes the three failed lanes on the previous revision below. + +The working-tree fix retains the original lock-wait policy and clears the PHP +file-status cache before both pathname checks without evicting realpath entries. +Fresh `lstat()` ownership/type checks and pre-open/handle/post-open inode matching +still fence stale path resolution. A process fixture replaces a primed parent +directory symlink and checks that allocation either uses the current file or +fails closed, leaving the old target untouched. Cross-process uniqueness +coverage now exercises reservation sizes one and sixteen. + +Remove redundant internal provider resolution after generator entry points have +already resolved the provider, skip reservation bookkeeping calls when disabled, +and keep opening/error handling together without an extra wrapper call. The +public provider/generator signatures and passed-context dispatch are unchanged. + +System-call profiling of 10,000 allocations reduced `newfstatat` calls from +30,726 to 20,728 while retaining both fresh pathname checks and handle checks. +The [PHP manual](https://www.php.net/manual/en/function.clearstatcache.php) +distinguishes file-status invalidation from optional realpath-cache eviction. +Sustained measurements and final-revision hosted confirmation remain required. + +An initial lock-backoff experiment passed sustained concurrency-five RPM but +regressed at concurrency fifty, so it was discarded. Keep the existing 2% budget +and require all affected workloads to pass; one improved lane cannot excuse +a regression in another. Keep this plan until final-revision acceptance closes +the other resource/lifecycle coverage requirements below. + +Local PHPForge processors, the detailed suite and final release guard passed on +the selected implementation: 229 tests / 4,094 assertions, zero dependency +advisories, and unchanged static/complexity/security gates. + +Final selected-source local host measurements used PHP 8.5.4, OPcache enabled, +JIT disabled, 64 CLI server children, production authoritative autoloaders and +four 60-second trials per release in balanced AB/BA order: + +| Workload | Tag 5.0 RPM | Working-copy RPM | Regression | 2% gate | +| --- | ---: | ---: | ---: | --- | +| Snowflake contention, concurrency 5 | 523986.55703 | 516127.45456 | 1.50% | Pass | +| Snowflake contention, concurrency 50 | 641233.69239 | 620850.10493 | 3.18% | Fail | + +Both pairs passed result-contract validation and reported stable trials and zero +failed responses, timeouts or within-response duplicates. Baseline/candidate +spreads were 1.44641%/0.87400% at concurrency five and 0.78674%/0.98569% at fifty. +Corresponding p99 values were 2.511/2.529 ms and 13.528/12.547 ms. PHPForge's +unchanged stable-environment comparison passed concurrency five and rejected +concurrency fifty; lower p99 does not excuse the RPM failure. + +The committed revision's hosted PHP 8.4 concurrency-fifty lane passed at +184408.87 → 183652.12 RPM (0.41% regression). A separate short local comparison +of exact `d3858dc` production source against the working copy measured +634802.05397 → 630573.07866 RPM (0.67% regression, four ten-second trials per +source). This supporting diagnostic does not certify a sustained 5.0 comparison +or establish that the PHP 8.5 regression is resolved. Keep local and hosted +runtime evidence separate and require exact-final-commit hosted confirmation. + +The selected production source also passed the no-optional-package smoke and +strict Sphinx build. Changes remain uncommitted; no release or tag was published. + ## 2026-10-07 cross-check Rechecked committed revision `5f62244d610ae77386d5d193dd86f1780eb9af8b` diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index e7b523e..48bfeb3 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -102,7 +102,9 @@ private function allocateLocked($handle, string $fileLocation, int $timestamp, ? $reservedEnd = $allocation + $reservationOffset; $runtime?->assertActive(); $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength); - $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd); + if ($this->reservationSize > 1) { + $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd); + } return $allocation; } @@ -179,10 +181,6 @@ private function storeReservation( int $allocation, int $reservedEnd, ): void { - if ($this->reservationSize === 1) { - return; - } - if (!isset($this->reservations[$fileLocation]) && count($this->reservations) >= self::MAX_RESERVATIONS) { throw new FileLockException('Sequence reservation domain limit exceeded'); } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 6df9b43..dfef213 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -117,54 +117,45 @@ static function (int $severity, string $message, string $file, int $line): never ); try { - return self::openVerifiedWithHandler($path, $errorMessage, $ownerId); - } catch (ErrorException $exception) { - throw new FileLockException($errorMessage, 0, $exception); - } finally { - restore_error_handler(); - } - } + // Refresh metadata; handle identity checks also fence cached path resolution. + clearstatcache(); - /** - * @return resource - * @throws FileLockException - * @throws ErrorException - */ - private static function openVerifiedWithHandler(string $path, string $errorMessage, ?int $ownerId) - { - clearstatcache(true, $path); + try { + $before = lstat($path); + } catch (ErrorException) { + $before = false; + } - 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); - 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); + } - 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); - 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); + } - $handle = fopen($path, 'r+b'); is_resource($handle) || throw new FileLockException($errorMessage); + chmod($path, 0600) || throw new FileLockException($errorMessage); - return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId); + return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId); + } catch (ErrorException $exception) { + throw new FileLockException($errorMessage, 0, $exception); + } finally { + restore_error_handler(); } - - is_resource($handle) || throw new FileLockException($errorMessage); - chmod($path, 0600) || throw new FileLockException($errorMessage); - - return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId); } /** @@ -176,7 +167,7 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri { try { $after = fstat($handle); - clearstatcache(true, $path); + clearstatcache(); $pathState = lstat($path); if ($after === false || $pathState === false) { throw new FileLockException($errorMessage); diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index 504f49f..e94b37b 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -101,11 +101,9 @@ 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); } diff --git a/src/TBSL.php b/src/TBSL.php index cb8800e..45ecde4 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -235,7 +235,7 @@ private static function resolveTail( bool $enableSequence, int $timeSequence, ClockBackwardPolicy $clockBackwardPolicy, - ?SequenceProviderInterface $sequenceProvider = null, + SequenceProviderInterface $sequenceProvider, ?GenerationContext $runtime = null, ): array { if (!$enableSequence) { diff --git a/tests/FilesystemSequenceV5Test.php b/tests/FilesystemSequenceV5Test.php index 6580871..e8dc94d 100644 --- a/tests/FilesystemSequenceV5Test.php +++ b/tests/FilesystemSequenceV5Test.php @@ -64,7 +64,7 @@ } }); -test('filesystem reservation ranges never overlap across processes', function () { +test('filesystem reservation ranges never overlap across processes', function (int $reservationSize) { expect(function_exists('pcntl_fork'))->toBeTrue() ->and(function_exists('pcntl_exec'))->toBeTrue(); @@ -78,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); @@ -108,7 +108,7 @@ } rmdir($directory); } -}); +})->with([1, 16]); test('coordinated generators remain unique across processes', function (string $algorithm) { expect(function_exists('pcntl_fork'))->toBeTrue() diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php index 5127bd5..9d09376 100644 --- a/tests/SequenceSafetyTest.php +++ b/tests/SequenceSafetyTest.php @@ -88,6 +88,51 @@ static function () use ($path, $handle, &$before) { } }); +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); From b90fb2301c137e698dae590f430ddca4b9e47a53 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 16:54:29 +0600 Subject: [PATCH 091/107] :art: refactor(sequence): simplify filesystem locking and snowflake internal state - Inline `allocateLocked` into `next` within `FilesystemSequenceProvider` to reduce method nesting :recycle: - Remove redundant `assertSameFile` and `getStartTimeStamp` helpers across file lock and snowflake modules :fire: - Update review documentation and release plan to reflect current acceptance benchmarks :memo: Refs: #6.0 --- docs/uid-review-and-release-plan.md | 273 ++++++-------------- src/Sequence/FilesystemSequenceProvider.php | 52 ++-- src/Snowflake.php | 20 +- src/Support/FileLock.php | 19 +- 4 files changed, 111 insertions(+), 253 deletions(-) diff --git a/docs/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md index 07003f6..99894c7 100644 --- a/docs/uid-review-and-release-plan.md +++ b/docs/uid-review-and-release-plan.md @@ -8,161 +8,72 @@ Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3c Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. Status: sections A–F implemented and regression-covered; section G, host performance, soak, and exact-final release acceptance remain open. -## Latest gate investigation (2026-10-07) - -Rechecked exact committed revision -`d3858dc537d08710502f3222ab9f1155bd7def30`. Security & Standards run -`37588152700` passed. Release Acceptance run `37588152012` passed diagnostics, -the five-minute soak and eleven of twelve host-performance lanes. The single -remaining failed job is `host-performance (snowflake-contended, 5, 1)`: - -| Metric | Tag 5.0 | Candidate | -| --- | ---: | ---: | -| Median successful RPM | 259926.06731 | 252941.12415 | -| Trial spread | 0.52804% | 0.87610% | -| p99 latency | 3.489 ms | 3.376 ms | - -Both sides were stable and recorded zero failed responses, timeouts and -within-response duplicates. The 2.69% RPM regression exceeds the unchanged 2% -budget. This supersedes the three failed lanes on the previous revision below. - -The working-tree fix retains the original lock-wait policy and clears the PHP -file-status cache before both pathname checks without evicting realpath entries. -Fresh `lstat()` ownership/type checks and pre-open/handle/post-open inode matching -still fence stale path resolution. A process fixture replaces a primed parent -directory symlink and checks that allocation either uses the current file or -fails closed, leaving the old target untouched. Cross-process uniqueness -coverage now exercises reservation sizes one and sixteen. - -Remove redundant internal provider resolution after generator entry points have -already resolved the provider, skip reservation bookkeeping calls when disabled, -and keep opening/error handling together without an extra wrapper call. The -public provider/generator signatures and passed-context dispatch are unchanged. - -System-call profiling of 10,000 allocations reduced `newfstatat` calls from -30,726 to 20,728 while retaining both fresh pathname checks and handle checks. -The [PHP manual](https://www.php.net/manual/en/function.clearstatcache.php) -distinguishes file-status invalidation from optional realpath-cache eviction. -Sustained measurements and final-revision hosted confirmation remain required. - -An initial lock-backoff experiment passed sustained concurrency-five RPM but -regressed at concurrency fifty, so it was discarded. Keep the existing 2% budget -and require all affected workloads to pass; one improved lane cannot excuse -a regression in another. Keep this plan until final-revision acceptance closes -the other resource/lifecycle coverage requirements below. - -Local PHPForge processors, the detailed suite and final release guard passed on -the selected implementation: 229 tests / 4,094 assertions, zero dependency -advisories, and unchanged static/complexity/security gates. - -Final selected-source local host measurements used PHP 8.5.4, OPcache enabled, -JIT disabled, 64 CLI server children, production authoritative autoloaders and -four 60-second trials per release in balanced AB/BA order: - -| Workload | Tag 5.0 RPM | Working-copy RPM | Regression | 2% gate | +## Current acceptance and simplification review (2026-10-07) + +Latest committed revision: `7721003b7306ff16f6f938af071e79ab81b05a0c`. +[Security & Standards run 37597332991](https://github.com/infocyph/UID/actions/runs/37597332991) +passed. [Release Acceptance run 37597332094](https://github.com/infocyph/UID/actions/runs/37597332094) +passed diagnostics, the five-minute soak and eleven of twelve host-performance +lanes. Snowflake contention at concurrency five failed: **222956.43 → 214712.95 +successful RPM, a 3.70% regression against the unchanged 2% budget**. +Earlier local or hosted results do not certify this revision or the working copy. + +The structural review follows PHPForge's existing engineering principles: +keep behavior in its cohesive owner, remove unnecessary forwarding, and retain +meaningful security, interoperability and lifecycle boundaries. Relative to +5.0, five production types were added and one removed (59 → 63 source files). The added types own: + +- Passed clock/wait policy (`GenerationContext`) and optional host-owned Runwire + adaptation (`RunwireBinding`). Neither starts workers or an event loop. +- Explicit stored formats (`SonyflakeFormat`, `RandflakeFormat`). These distinguish + legacy compatibility from upstream layouts without silently reinterpreting IDs. +- The upstream cipher (`Sparx64`). This owns the independently tested algorithm. + +The existing `BinaryUnpack` type was expanded for shared decoding; it is not a +new file. The source count is a review signal, not an architectural quality score. + +The working copy adds no production types or files. Filesystem allocation and +its lock lifetime stay together in `next()`, eliminating `allocateLocked()`. +Inode comparisons stay in handle verification, eliminating `assertSameFile()`. +Snowflake reads its epoch constant directly, eliminating `getStartTimeStamp()`. +Complete provider/domain preparation before sampling the allocation timestamp. +Keep fresh pathname metadata, owner/type checks, pre/post-open inode verification, +integer exhaustion checks, cancellation forwarding, bounded waits, explicit +unlocking and provider-owned history. Public contracts and gate budgets remain +unchanged. + +Selected-source sustained comparisons use production authoritative autoloaders, +64 CLI server children, OPcache enabled, JIT disabled, and four 60-second trials +per release in balanced AB/BA order. The PHP 8.4 servers run in matching +disposable containers (8.4.26); the benchmark client runs PHP 8.5.4. These are +local matched comparisons, not hosted certification. + +| Server runtime / concurrency | Tag 5.0 RPM | Working-copy RPM | Regression | 2% gate | | --- | ---: | ---: | ---: | --- | -| Snowflake contention, concurrency 5 | 523986.55703 | 516127.45456 | 1.50% | Pass | -| Snowflake contention, concurrency 50 | 641233.69239 | 620850.10493 | 3.18% | Fail | - -Both pairs passed result-contract validation and reported stable trials and zero -failed responses, timeouts or within-response duplicates. Baseline/candidate -spreads were 1.44641%/0.87400% at concurrency five and 0.78674%/0.98569% at fifty. -Corresponding p99 values were 2.511/2.529 ms and 13.528/12.547 ms. PHPForge's -unchanged stable-environment comparison passed concurrency five and rejected -concurrency fifty; lower p99 does not excuse the RPM failure. - -The committed revision's hosted PHP 8.4 concurrency-fifty lane passed at -184408.87 → 183652.12 RPM (0.41% regression). A separate short local comparison -of exact `d3858dc` production source against the working copy measured -634802.05397 → 630573.07866 RPM (0.67% regression, four ten-second trials per -source). This supporting diagnostic does not certify a sustained 5.0 comparison -or establish that the PHP 8.5 regression is resolved. Keep local and hosted -runtime evidence separate and require exact-final-commit hosted confirmation. - -The selected production source also passed the no-optional-package smoke and -strict Sphinx build. Changes remain uncommitted; no release or tag was published. - -## 2026-10-07 cross-check - -Rechecked committed revision `5f62244d610ae77386d5d193dd86f1780eb9af8b` -and the complete implementation against the acceptance requirements below. -The plan remains because the release gates are not complete. - -Additional working-tree corrections and regressions: - -- Forward each generator config's `GenerationContext` to built-in filesystem and - PSR-16 allocation without storing a request binding on the shared provider. - Contended-lock tests exercise intermediary forwarding, other-task progress and - host cancellation through this config-only path. -- Check cancellation/completion before fresh and reserved provider allocation, - after a cache read and immediately before mutation; preserve terminal binding - failures through synchronizer/error handling. -- Refresh lock pathname metadata rather than trusting PHP's cached `lstat()` - result. A process replacement fixture verifies rejection of an externally - replaced path while leaving its target untouched. -- Associate TBSL rollback history weakly with the actual provider and machine, - preserving independent clocks. Validate its 60-bit timestamp before normal - allocation and correctly parse eleven-digit Unix seconds. -- Reject Sonyflake epochs in the future even within one 10 ms tick, and reject - extreme epochs before overflowing integer division. -- Keep clock and wait calculations in the supported integer domain, reject - overflowing inclusive-to-exclusive Randflake leases, recheck live Randflake - provider state after reentrant/suspending allocation, and reject times before - Randflake's epoch before allocation. -- Bound implicit ULID/UUIDv7 rollover waits and fail promptly at timestamp - exhaustion; require exact UUID node width including end of input. -- Validate host response IDs against the workload format and exclude duplicate - responses from successful throughput. Protect even-sample median/trial metadata - behavior with regression coverage. - -Hosted Security & Standards run `37584251034` passed on `5f62244`. -Hosted Release Acceptance run `37584250265` passed diagnostics and the existing -five-minute soak, but failed the following required host gates: - -| Workload | Baseline RPM | Candidate RPM | Failure | -| --- | ---: | ---: | --- | -| Snowflake contention, concurrency 1 | 136025.70070 | 131210.32508 | 3.54% regression; 2% budget | -| Snowflake contention, concurrency 50 | 556309.78158 | 539240.87060 | 3.07% regression; 2% budget | -| CUID2 single ID, concurrency 50 | 213626.22967 | 387476.98209 | Candidate spread 16.47568%; 15% stability ceiling | - -All three downloaded pairs report zero failed responses, timeouts and -within-response duplicates. CUID2's gain does not excuse its unstable trials. -These results certify neither the new working-tree corrections nor a 6.0 release. - -The full plan also requires acceptance coverage that the current harness has not -yet supplied: host CPU/peak and steady RSS fields are null; worker readiness is -inferred from request counts rather than verified per worker; HTTP duplicate -detection is within responses rather than across the measured workload; queue, -lock-wait and predefined resource/latency ceilings are absent. The soak exercises -in-memory generation and cancellation, but not contention, released provider -domains or worker replacement. The Runwire profile compares CPU generation only -and does not measure its intended scheduling benefit under contention. -Keep these gates open; do not remove this plan or tag 6.0 until final-revision -evidence closes them without weakening correctness, security or budgets. - -Local verification of the corrected source on PHP 8.5.4: PHPForge processors, -the full detailed suite and the final release guard passed. The final guard ran -227 tests / 4,074 assertions and reported zero dependency advisories; the existing -transitive development-only `doctrine/annotations` abandonment remains a warning. -The strict Sphinx build (`-n -W --keep-going`) passed. A clean authoritative -`--no-dev` installation executed native generators, both format modes, value -metadata and codecs with Runwire, PSR-20 and PSR-16 absent. The pathname replacement -regression was independently run with the committed old opener and failed there. - -Short paired PHP 8.5.4 diagnostics (four trials of five seconds, not the required -sustained acceptance) on the corrected production source retained zero response -errors, timeouts and within-response duplicates. Snowflake concurrency 1 measured -384004.51513 → 374224.65428 RPM (2.55% regression); concurrency 50 measured -652734.08177 → 641236.76811 RPM (1.76% regression). Both pairs were stable under -the existing spread rule. These short diagnostics leave the required sustained -Snowflake gate open and do not replace exact-final PHP 8.4/8.5 hosted evidence. - -This plan follows `vendor/infocyph/phpforge/resources/engineering-principles.md`: -correctness and security precede performance; preserve public contracts and named -arguments; distinguish required changes from optional features; keep dependencies -and abstractions justified; use successful host RPM as the performance criterion; -resolve quality findings at their cause without suppressions or weaker gates. -No production code or dependency constraints were changed during this review. +| PHP 8.4 / 5 | 454859.98712 | 445766.25900 | 1.99923677% | Pass, narrow margin | +| PHP 8.4 / 50 | 627388.07439 | 619860.29262 | 1.20% | Pass | +| PHP 8.5 / 50 | 656077.42157 | 644697.62155 | 1.73% | Pass | + +PHPForge result-contract validation and stable-environment comparison passed +all three pairs without changing thresholds. PHP 8.4 baseline/candidate trial +spreads were 1.29221%/0.80187% at concurrency five and 0.58974%/1.34755% at fifty; +PHP 8.5 concurrency-fifty spreads were 1.29381%/0.44432%. All report +zero failed responses, timeouts or within-response duplicates; single-ID +responses do not establish global HTTP uniqueness. CPU/RSS remain unreported +by this harness. The concurrency-five margin is less than 0.001 percentage point, +so independent hosted confirmation on the final commit is essential. + +A short PHP 8.5 concurrency-five diagnostic measured a 0.49% gain (four +15-second trials per release), with spreads of 8.26%/7.98%. This does not replace +a sustained comparison. The selected-source sustained PHP 8.5 concurrency-fifty +recheck passed at 1.73%; the prior source measured 3.18% on that local runtime. +The runs occurred separately and do not isolate the effect of individual edits. + +Local processors, detailed quality checks and the final release guard passed: +229 tests / 4,094 assertions. Strict documentation and clean production smoke +checks passed. Changes are uncommitted. Keep this plan until exact-final-commit +hosted confirmation and the resource/lifecycle acceptance requirements below +are complete. No release/tag is published. ## Complete planned scope @@ -203,34 +114,22 @@ Graphify supplied navigation; findings below were verified in source and with targeted PHP probes. Reflection was used only to reach otherwise impractical counter boundaries, not as evidence that attackers can mutate private state. -Current local evidence on 64-bit PHP 8.5.4: +Final selected-source local evidence on 64-bit PHP 8.5.4: | Check | Result | | --- | --- | -| `composer ic:doctor` / `composer ic:list-config` | Doctor healthy; configurations resolve from PHPForge | -| `composer validate --strict` | Passed | -| `composer ic:test:code` | 159 tests, 1,531 assertions, passed; process/fork tests executed | -| `composer ic:tests` | Failed skip-directive scanner and PHPStan configuration validation | -| Other full-suite stages | Normalize, syntax, references, duplicates, comments, Pest, Pint, PHPCS, Deptrac, Psalm and Rector passed | -| `composer audit --locked --format=json` | Zero advisories; abandoned development dependency `doctrine/annotations`; audit exits 1 for abandonment | -| `composer ic:release:guard` with network access | Audit completed with abandonment treated as a warning; guard failed at skip scanning/configuration | -| TypeID upstream vectors | All 9 valid encoding/decoding cases and 21 invalid cases passed | -| Targeted adversarial probes | Reproduced findings R01–R11 below | - -The live [Security & Standards run](https://github.com/infocyph/UID/actions/runs/37178593330) -for the reviewed SHA failed all four QA lanes: PHP 8.4/8.5 with prefer-stable and -prefer-lowest. Its analysis lanes, clean install and component benchmark passed. -Job details show failures at `Run quality suite once`; the requested QA log -download returned empty output, so its precise diagnostic is not attributed to -the local failure. Benchmark result validation and regression comparison were -skipped: a green component benchmark job is not a 2% host-RPM certification. - -PHP 8.2/8.3 execution, a Windows run, representative host throughput and a long -persistent-worker soak were not performed. The existing documented August -component timings are historical supporting evidence, not current release gates. -Zero published dependency advisories does not establish absence of code defects. -The PHP 8.2/8.3 coverage gap describes the reviewed 5.x tree; the next release's -required runtime matrix begins at PHP 8.4. +| PHPForge doctor and configuration inspection | Healthy; PHP 8.4/8.5 matrix and vendor-owned quality configuration | +| Processors, detailed checks and release guard | Passed; 229 tests / 4,094 assertions, including process/fork tests | +| Static analysis, taint analysis and complexity limits | Passed without suppressions or threshold changes | +| Dependency audit | Zero advisories; one non-blocking abandoned development dependency (`doctrine/annotations`) | +| Strict Sphinx build | Passed | +| Clean production smoke without optional peers | Passed; native generation, formats, values and codecs | + +The original review reproduced findings R01–R11 below. Their implementation and +regression coverage are recorded in sections A–F. Historical component timings +and green QA do not replace the sustained host and lifecycle requirements. The +next release's required runtime matrix begins at PHP 8.4. Exact-final-commit +hosted acceptance remains open for the uncommitted simplification changes. ## Required findings @@ -588,22 +487,6 @@ do not assert an improvement solely from historical microsecond timings. ## Performance and release gates -Previous committed hosted evidence, superseded by the cross-check above: -Security & Standards run `37572646512` on -`6a302ef2c72f9d0e499b4cbc6ff6c97d971819a9` passed clean install, component -benchmarks on PHP 8.4/8.5, analysis on PHP 8.4/8.5, and all four stable/lowest QA -lanes. This is implementation QA evidence, not host-RPM or soak certification. - -Release Acceptance run `37572646018` on that revision passed diagnostics and the -persistent-worker soak, but six host lanes exceeded the 2% RPM budget: CUID2 batch -at concurrency 1, 5, 20 and 50, and Snowflake contention at concurrency 1 and 5. -Working-tree remediation adds grouped radix encoding with independent legacy -vectors, omits unused default-provider reservation bookkeeping, reduces repeated -lock ownership lookups, and corrects warm host cache -configuration and median/report handling. All final hosted gates must run again -on the revision containing these changes; earlier soak or QA results do not -certify the modified candidate. - - [ ] Measure corrected code against tag `5.0` with matching runtimes, dependencies, hardware and deployment configuration. Separate pure-generator, filesystem, reservation, PSR-16 and optional Runwire-bound workloads. diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 48bfeb3..741c711 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -73,40 +73,32 @@ public function next(string $type, int $machineId, int $timestamp, ?GenerationCo ); try { - return $this->allocateLocked($handle, $fileLocation, $timestamp, $runtime); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } - } + [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); + if ($lastTimestamp > $timestamp) { + throw new SequenceTimestampException($lastTimestamp, $timestamp); + } + if ($lastTimestamp === $timestamp && $lastAllocation === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted'); + } - /** - * @param resource $handle - */ - private function allocateLocked($handle, string $fileLocation, int $timestamp, ?GenerationContext $runtime): int - { - [$lastTimestamp, $lastAllocation, $oldLength] = $this->readState($handle); - 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; + $reservationOffset = $this->reservationSize - 1; + if ($allocation > PHP_INT_MAX - $reservationOffset) { + throw new FileLockException('Sequence value exhausted'); + } - $allocation = $lastTimestamp === $timestamp ? $lastAllocation + 1 : 1; - $reservationOffset = $this->reservationSize - 1; - if ($allocation > PHP_INT_MAX - $reservationOffset) { - throw new FileLockException('Sequence value exhausted'); - } + $reservedEnd = $allocation + $reservationOffset; + $runtime?->assertActive(); + $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength); + if ($this->reservationSize > 1) { + $this->storeReservation($fileLocation, $timestamp, $allocation, $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 { + flock($handle, LOCK_UN); + fclose($handle); } - - return $allocation; } /** diff --git a/src/Snowflake.php b/src/Snowflake.php index 7f17c97..2e582a6 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -73,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, ); } @@ -91,7 +91,7 @@ public static function generateWithConfig(SnowflakeConfig $config): string return self::generateInternal( $datacenterId, $workerId, - $customEpoch ?? self::getStartTimeStamp(), + $customEpoch ?? self::DEFAULT_EPOCH, $config->clockBackwardPolicy, $config->sequenceProvider, $config->runtime, @@ -119,7 +119,7 @@ public static function parse(string $id): array { return self::parseWithEpoch( id: $id, - startTimestamp: self::getStartTimeStamp(), + startTimestamp: self::DEFAULT_EPOCH, ); } @@ -263,9 +263,6 @@ private static function generateInternal( ): string { self::assertNodeIds($datacenter, $workerId); - $currentTime = self::nowMilliseconds($runtime); - self::assertTimestampRange($currentTime, $startTimestamp); - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); $sequenceKey = ($datacenter << self::WORKER_BITS) | $workerId; $stateKey = $startTimestamp . ':' . $sequenceKey; @@ -275,6 +272,9 @@ private static function generateInternal( ? 'snowflake' : 'snowflake_' . $startTimestamp; + $currentTime = self::nowMilliseconds($runtime); + self::assertTimestampRange($currentTime, $startTimestamp); + while (true) { [$currentTime, $sequence] = self::nextSequenceAtValidTimestamp( $currentTime, @@ -323,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 diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index dfef213..026fe6e 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -79,17 +79,6 @@ private static function assertSafeMetadata(array $metadata, string $errorMessage } } - /** - * @param array $left - * @param array $right - */ - private static function assertSameFile(array $left, array $right, string $errorMessage): void - { - if ($left['dev'] !== $right['dev'] || $left['ino'] !== $right['ino']) { - throw new FileLockException($errorMessage); - } - } - private static function lockDeadline(?int $timeoutMicros, ?GenerationContext $runtime): int { $timeout = $timeoutMicros ?? $runtime->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; @@ -175,9 +164,11 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri self::assertSafeMetadata($after, $errorMessage, $ownerId); self::assertSafeMetadata($pathState, $errorMessage, $ownerId); - self::assertSameFile($after, $pathState, $errorMessage); - if ($before !== null) { - self::assertSameFile($before, $after, $errorMessage); + if ( + $after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino'] + || ($before !== null && ($before['dev'] !== $after['dev'] || $before['ino'] !== $after['ino'])) + ) { + throw new FileLockException($errorMessage); } return $handle; From dfe2370c4fe529a34a4ffc1e70692883e06f65d7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:15:20 +0600 Subject: [PATCH 092/107] :fire: chore(ci): remove release performance harnesses and add production smoke workflow - Cut benchmarks/release/ComponentProfile.php :fire: - Cut benchmarks/release/HostBenchmark.php :fire: - Cut benchmarks/release/RunwireProfile.php :fire: - Cut benchmarks/release/host-router.php :fire: - Cut benchmarks/release/soak-worker.php :fire: - Cut docs/uid-review-and-release-plan.md :fire: - Add tests/smoke.php to run a 100-cycle production check :sparkles: - Update release-acceptance workflow to test PHP 8.4 and 8.5 via smoke pass :wrench: --- .github/workflows/release-acceptance.yml | 303 +---------- README.md | 4 + benchmarks/release/ComponentProfile.php | 153 ------ benchmarks/release/HostBenchmark.php | 632 ----------------------- benchmarks/release/RunwireProfile.php | 96 ---- benchmarks/release/host-router.php | 91 ---- benchmarks/release/soak-worker.php | 153 ------ docs/benchmark-report.rst | 36 +- docs/uid-review-and-release-plan.md | 549 -------------------- src/Snowflake.php | 4 +- src/Support/FileLock.php | 19 +- tests/HostBenchmarkAcceptanceTest.php | 26 - tests/RuntimeIntegrationTest.php | 45 ++ tests/smoke.php | 140 +++++ 14 files changed, 242 insertions(+), 2009 deletions(-) delete mode 100644 benchmarks/release/ComponentProfile.php delete mode 100644 benchmarks/release/HostBenchmark.php delete mode 100644 benchmarks/release/RunwireProfile.php delete mode 100644 benchmarks/release/host-router.php delete mode 100644 benchmarks/release/soak-worker.php delete mode 100644 docs/uid-review-and-release-plan.md delete mode 100644 tests/HostBenchmarkAcceptanceTest.php create mode 100644 tests/smoke.php diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 2e7f024..f4f1239 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -1,311 +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/**" - - "benchmarks/**" + - "tests/**" - "composer.json" - - "docs/uid-review-and-release-plan.md" - ".github/workflows/release-acceptance.yml" permissions: contents: read jobs: - diagnostics: - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - XDEBUG_MODE: off - steps: - - name: Checkout exact candidate - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup PHP 8.4 - uses: shivammathur/setup-php@v2 - with: - php-version: "8.4" - tools: composer:v2 - extensions: ctype,curl,pcntl - coverage: none - - - name: Install candidate tooling - shell: bash - run: | - composer install --no-interaction --prefer-dist --no-progress - mkdir -p .phpforge-report - - - name: Run candidate release guard - shell: bash - run: composer ic:release:guard - - - name: Checkout tag 5.0 - uses: actions/checkout@v7 - with: - ref: "5.0" - path: ".release-baseline" - - - name: Checkout candidate production target - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: ".release-candidate" - - - name: Install production targets - shell: bash - run: | - composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - - - name: Profile components against tag 5.0 - shell: bash - run: | - php benchmarks/release/ComponentProfile.php --target-root="$GITHUB_WORKSPACE/.release-baseline" --release="5.0" --output=".phpforge-report/component-baseline.json" - php benchmarks/release/ComponentProfile.php --target-root="$GITHUB_WORKSPACE/.release-candidate" --release="candidate" --output=".phpforge-report/component-candidate.json" - php -r ' - $b=json_decode(file_get_contents(".phpforge-report/component-baseline.json"),true,512,JSON_THROW_ON_ERROR); - $c=json_decode(file_get_contents(".phpforge-report/component-candidate.json"),true,512,JSON_THROW_ON_ERROR); - foreach ($b["metrics"] as $name=>$metric) { - if (!isset($c["metrics"][$name])) continue; - $before=$metric["median_ns"]; - $after=$c["metrics"][$name]["median_ns"]; - $change=$before > 0 ? (($after-$before)/$before)*100 : 0; - printf("%-36s %10.3f ns -> %10.3f ns (%+.2f%%)\n",$name,$before,$after,$change); - } - ' - - - name: Run contention matrix before and after - shell: bash - run: | - php -r ' - require ".release-baseline/vendor/autoload.php"; - require "benchmarks/ContentionMatrix.php"; - Infocyph\UID\Benchmarks\ContentionMatrix::run([1,4,16],[1,8,64],200); - ' > .phpforge-report/contention-baseline.csv - php -r ' - require ".release-candidate/vendor/autoload.php"; - require "benchmarks/ContentionMatrix.php"; - Infocyph\UID\Benchmarks\ContentionMatrix::run([1,4,16],[1,8,64],200); - ' > .phpforge-report/contention-candidate.csv - cat .phpforge-report/contention-baseline.csv - cat .phpforge-report/contention-candidate.csv - - - name: Profile Runwire-bound path separately - shell: bash - run: | - php benchmarks/release/RunwireProfile.php .phpforge-report/runwire-profile.json - cat .phpforge-report/runwire-profile.json - - - name: Upload diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: uid-6-release-diagnostics - path: .phpforge-report - retention-days: 14 - if-no-files-found: error - include-hidden-files: true - - host-performance: - needs: diagnostics + production-smoke: + name: Production smoke (PHP ${{ matrix.php }}) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 strategy: fail-fast: false matrix: - include: - - route: cuid2-one - concurrency: 1 - ids_per_response: 1 - - route: cuid2-one - concurrency: 5 - ids_per_response: 1 - - route: cuid2-one - concurrency: 20 - ids_per_response: 1 - - route: cuid2-one - concurrency: 50 - ids_per_response: 1 - - route: cuid2-batch - concurrency: 1 - ids_per_response: 100 - - route: cuid2-batch - concurrency: 5 - ids_per_response: 100 - - route: cuid2-batch - concurrency: 20 - ids_per_response: 100 - - route: cuid2-batch - concurrency: 50 - ids_per_response: 100 - - route: snowflake-contended - concurrency: 1 - ids_per_response: 1 - - route: snowflake-contended - concurrency: 5 - ids_per_response: 1 - - route: snowflake-contended - concurrency: 20 - ids_per_response: 1 - - route: snowflake-contended - concurrency: 50 - ids_per_response: 1 - env: - XDEBUG_MODE: off - steps: - - name: Checkout exact candidate tooling - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Checkout tag 5.0 - uses: actions/checkout@v7 - with: - ref: "5.0" - path: ".release-baseline" - - - name: Checkout candidate production target - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: ".release-candidate" - - - name: Setup PHP 8.4 - uses: shivammathur/setup-php@v2 - with: - php-version: "8.4" - tools: composer:v2 - extensions: ctype,curl - ini-values: opcache.enable_cli=1,opcache.jit=0 - coverage: none - - - name: Install benchmark tooling and targets - shell: bash - run: | - composer install --no-interaction --prefer-dist --no-progress - composer --working-dir=.release-baseline install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - composer --working-dir=.release-candidate install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - mkdir -p .phpforge-report "$RUNNER_TEMP/uid-baseline-state" "$RUNNER_TEMP/uid-candidate-state" - - - name: Run interleaved paired host benchmark - shell: bash - env: - ROUTE: ${{ matrix.route }} - CONCURRENCY: ${{ matrix.concurrency }} - IDS_PER_RESPONSE: ${{ matrix.ids_per_response }} - run: | - WARMUP=$(( CONCURRENCY * 2 )) - if [ "$WARMUP" -lt 130 ]; then WARMUP=130; fi - - UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-baseline" \ - UID_STATE_DIR="$RUNNER_TEMP/uid-baseline-state" \ - PHP_CLI_SERVER_WORKERS=64 \ - php -S 127.0.0.1:18080 benchmarks/release/host-router.php \ - > .phpforge-report/baseline-server.log 2>&1 & - baseline_pid=$! - - UID_TARGET_ROOT="$GITHUB_WORKSPACE/.release-candidate" \ - UID_STATE_DIR="$RUNNER_TEMP/uid-candidate-state" \ - PHP_CLI_SERVER_WORKERS=64 \ - php -S 127.0.0.1:18081 benchmarks/release/host-router.php \ - > .phpforge-report/candidate-server.log 2>&1 & - candidate_pid=$! - - trap 'kill "$baseline_pid" "$candidate_pid" 2>/dev/null || true' EXIT - - for port in 18080 18081; do - for attempt in {1..50}; do - if curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null; then break; fi - sleep 0.2 - done - curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null - done - - php benchmarks/release/HostBenchmark.php \ - --baseline-url=http://127.0.0.1:18080 \ - --candidate-url=http://127.0.0.1:18081 \ - --baseline-output=.phpforge-report/host-baseline.json \ - --candidate-output=.phpforge-report/host-candidate.json \ - --route="$ROUTE" \ - --concurrency="$CONCURRENCY" \ - --duration=60 \ - --repetitions=4 \ - --ids-per-response="$IDS_PER_RESPONSE" \ - --warmup="$WARMUP" - - kill "$baseline_pid" "$candidate_pid" 2>/dev/null || true - wait "$baseline_pid" 2>/dev/null || true - wait "$candidate_pid" 2>/dev/null || true - trap - EXIT - - - name: Enforce 2 percent host budget - shell: bash - run: | - composer ic:benchmark:validate .phpforge-report/host-baseline.json - composer ic:benchmark:validate .phpforge-report/host-candidate.json - composer ic:benchmark:compare .phpforge-report/host-baseline.json .phpforge-report/host-candidate.json --max-regression=2 --stable-environment - - - name: Upload host evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: uid-host-${{ matrix.route }}-c${{ matrix.concurrency }} - path: .phpforge-report - retention-days: 14 - if-no-files-found: error - include-hidden-files: true - - persistent-worker-soak: - needs: diagnostics - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - XDEBUG_MODE: off - UID_SOAK_RESULT: ${{ github.workspace }}/.phpforge-report/uid-soak-internal.json + php: ["8.4", "8.5"] steps: - name: Checkout exact candidate uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Setup PHP 8.4 + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.4" + php-version: ${{ matrix.php }} tools: composer:v2 - extensions: ctype,pcntl + extensions: ctype coverage: none - - name: Install candidate tooling - shell: bash - run: | - composer install --no-interaction --prefer-dist --no-progress - mkdir -p .phpforge-report + - name: Install production dependencies + run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative - - name: Run five-minute persistent-worker soak - shell: bash - run: | - composer ic:soak:worker --duration=300 --warmup=10 --sample-interval=2 --max-growth-mb=32 --report=.phpforge-report/phpforge-soak.json -- php benchmarks/release/soak-worker.php - - php -r ' - $result=json_decode(file_get_contents(".phpforge-report/uid-soak-internal.json"),true,512,JSON_THROW_ON_ERROR); - if (($result["status"] ?? null) !== "passed") { - fwrite(STDERR,json_encode($result,JSON_PRETTY_PRINT).PHP_EOL); - exit(1); - } - echo json_encode($result,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES),PHP_EOL; - ' - - - name: Upload soak evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: uid-6-persistent-worker-soak - path: .phpforge-report - retention-days: 14 - if-no-files-found: error - include-hidden-files: true + - name: Check every generator and format in one 100-cycle pass + run: php tests/smoke.php diff --git a/README.md b/README.md index 791106d..b062625 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,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/benchmarks/release/ComponentProfile.php b/benchmarks/release/ComponentProfile.php deleted file mode 100644 index 9c82089..0000000 --- a/benchmarks/release/ComponentProfile.php +++ /dev/null @@ -1,153 +0,0 @@ - round($median, 3), - 'min_ns' => round(min($samples), 3), - 'max_ns' => round(max($samples), 3), - ]; -} - -$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 profiling helpers'); -} - -($fingerprint)(); - -$metrics = [ - 'cuid2_generate_warm' => uidProfile(static fn(): string => CUID2::generate(), 2_000), - 'cuid2_generate_cold' => uidProfile( - static function () use ($resetFingerprint): string { - $resetFingerprint(); - - return CUID2::generate(); - }, - 250, - ), - 'cuid2_fingerprint_warm' => uidProfile($fingerprint, 5_000), - 'cuid2_fingerprint_cold' => uidProfile( - static function () use ($resetFingerprint, $fingerprint): string { - $resetFingerprint(); - - return $fingerprint(); - }, - 250, - ), -]; - -$samples = []; -$decimals = []; -$encoded = []; - -foreach ([8, 10, 12, 16, 20, 32, 64] as $length) { - $material = ''; - $counter = 0; - - while (strlen($material) < $length) { - $material .= hash('sha256', 'uid-profile-' . $length . '-' . $counter, true); - ++$counter; - } - - $samples[$length] = substr($material, 0, $length); - $decimals[$length] = DecimalBytes::fromBytes($samples[$length]); - - foreach ([10, 16, 32, 36, 58, 62] as $base) { - $encoded[$base][$length] = BaseEncoder::encodeBytes($samples[$length], $base); - $metrics['base' . $base . '_encode_' . $length] = uidProfile( - static fn(): string => BaseEncoder::encodeBytes($samples[$length], $base), - 500, - ); - $metrics['base' . $base . '_decode_' . $length] = uidProfile( - static fn(): string => BaseEncoder::decodeToBytes( - $encoded[$base][$length], - $base, - $length, - ), - 500, - ); - } - - $metrics['numeric_from_bytes_' . $length] = uidProfile( - static fn(): string => NumericIdCodec::decimalFromBytes($samples[$length], $length), - 500, - ); - $metrics['numeric_to_bytes_' . $length] = uidProfile( - static fn(): string => DecimalBytes::toFixedBytes($decimals[$length], $length), - 500, - ); -} - -$typeId = TypeIdCodec::encode($samples[16]); -$metrics['typeid_encode_16'] = uidProfile( - static fn(): string => TypeIdCodec::encode($samples[16]), - 1_000, -); -$metrics['typeid_decode_16'] = uidProfile( - static fn(): string => TypeIdCodec::decode($typeId), - 1_000, -); - -file_put_contents( - $output, - json_encode([ - 'release' => $release, - 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'php_version' => PHP_VERSION, - 'metrics' => $metrics, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, -); diff --git a/benchmarks/release/HostBenchmark.php b/benchmarks/release/HostBenchmark.php deleted file mode 100644 index c587920..0000000 --- a/benchmarks/release/HostBenchmark.php +++ /dev/null @@ -1,632 +0,0 @@ - $values - */ -function uidPercentile(array $values, float $percentile): float -{ - if ($values === []) { - return 0.0; - } - - sort($values, SORT_NUMERIC); - $index = max(0, (int) ceil(count($values) * $percentile) - 1); - - return $values[$index]; -} - -/** - * @param list $values - */ -function uidAverage(array $values): float -{ - return $values === [] ? 0.0 : array_sum($values) / count($values); -} - -function uidCreateHandle(string $url): CurlHandle -{ - $handle = curl_init($url); - $handle instanceof CurlHandle || throw new RuntimeException('Unable to create benchmark request handle'); - - curl_setopt_array($handle, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT_MS => 2_000, - CURLOPT_TIMEOUT_MS => 5_000, - CURLOPT_HTTPHEADER => ['Accept: application/json'], - ]); - - return $handle; -} - -function uidValidId(string $id, string $route): bool -{ - if ($route === '/snowflake-contended') { - return preg_match('/\A(?:0|[1-9][0-9]{0,18})\z/', $id) === 1 - && (strlen($id) < 19 || strcmp($id, (string) PHP_INT_MAX) <= 0); - } - - return in_array($route, ['/cuid2-one', '/cuid2-batch'], true) - && preg_match('/\A[a-z][a-z0-9]{23}\z/', $id) === 1; -} - -/** - * @param array{result:int,handle:CurlHandle} $info - * @return array{successful:bool,timeout:bool,latency:float,duplicates:int} - */ -function uidInspectCompletion(array $info, int $idsPerResponse): array -{ - $handle = $info['handle']; - $body = curl_multi_getcontent($handle); - $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); - $latency = (float) curl_getinfo($handle, CURLINFO_TOTAL_TIME) * 1_000; - $successful = $info['result'] === CURLE_OK && $httpCode === 200 && is_string($body); - $decoded = $successful ? json_decode($body, true) : null; - $ids = is_array($decoded) ? ($decoded['ids'] ?? null) : null; - - if (!is_array($ids) || count($ids) !== $idsPerResponse) { - $successful = false; - $ids = []; - } - - $duplicates = 0; - $responseIds = []; - $route = (string) parse_url((string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL), PHP_URL_PATH); - - foreach ($ids as $id) { - if (!is_string($id) || !uidValidId($id, $route)) { - $successful = false; - - continue; - } - - if (isset($responseIds[$id])) { - ++$duplicates; - } else { - $responseIds[$id] = true; - } - } - - return [ - 'successful' => $successful && $duplicates === 0, - 'timeout' => $info['result'] === CURLE_OPERATION_TIMEDOUT, - 'latency' => $latency, - 'duplicates' => $duplicates, - ]; -} - -/** - * @return array{attempted:int,successful:int,failed:int,timeouts:int,duplicates:int} - */ -function uidRunOperations( - string $url, - int $concurrency, - int $operations, - int $idsPerResponse, -): array { - $multi = curl_multi_init(); - $launched = 0; - $active = 0; - $attempted = 0; - $successful = 0; - $failed = 0; - $timeouts = 0; - $duplicates = 0; - - $launch = static function () use ($multi, $url, $operations, &$launched, &$active): void { - if ($launched >= $operations) { - return; - } - - curl_multi_add_handle($multi, uidCreateHandle($url)); - ++$launched; - ++$active; - }; - - for ($index = 0; $index < min($concurrency, $operations); ++$index) { - $launch(); - } - - while ($active > 0) { - do { - $status = curl_multi_exec($multi, $running); - } while ($status === CURLM_CALL_MULTI_PERFORM); - - $status === CURLM_OK || throw new RuntimeException('Host benchmark warmup execution failed'); - - while (($info = curl_multi_info_read($multi)) !== false) { - $result = uidInspectCompletion($info, $idsPerResponse); - ++$attempted; - - if ($result['successful']) { - ++$successful; - } else { - ++$failed; - } - - if ($result['timeout']) { - ++$timeouts; - } - - $duplicates += $result['duplicates']; - curl_multi_remove_handle($multi, $info['handle']); - --$active; - $launch(); - } - - if ($running > 0) { - $selected = curl_multi_select($multi, 0.5); - if ($selected === -1) { - usleep(1_000); - } - } - } - - unset($multi); - - return [ - 'attempted' => $attempted, - 'successful' => $successful, - 'failed' => $failed, - 'timeouts' => $timeouts, - 'duplicates' => $duplicates, - ]; -} - -/** - * @return array{ - * attempted:int, - * successful:int, - * failed:int, - * timeouts:int, - * rpm:float, - * latencies:list, - * duplicates:int, - * elapsed_seconds:float - * } - */ -function uidRunDuration( - string $url, - int $concurrency, - int $durationSeconds, - int $idsPerResponse, -): array { - $multi = curl_multi_init(); - $active = 0; - $attempted = 0; - $successful = 0; - $failed = 0; - $timeouts = 0; - $duplicates = 0; - $latencies = []; - $started = hrtime(true); - $stopAt = $started + ($durationSeconds * 1_000_000_000); - - for ($index = 0; $index < $concurrency; ++$index) { - curl_multi_add_handle($multi, uidCreateHandle($url)); - ++$active; - } - - while ($active > 0) { - do { - $status = curl_multi_exec($multi, $running); - } while ($status === CURLM_CALL_MULTI_PERFORM); - - $status === CURLM_OK || throw new RuntimeException('Host benchmark curl multi execution failed'); - - while (($info = curl_multi_info_read($multi)) !== false) { - $result = uidInspectCompletion($info, $idsPerResponse); - ++$attempted; - - if ($result['successful']) { - ++$successful; - if (count($latencies) < UID_MAX_LATENCY_SAMPLES) { - $latencies[] = $result['latency']; - } - } else { - ++$failed; - } - - if ($result['timeout']) { - ++$timeouts; - } - - $duplicates += $result['duplicates']; - curl_multi_remove_handle($multi, $info['handle']); - --$active; - - if (hrtime(true) < $stopAt) { - curl_multi_add_handle($multi, uidCreateHandle($url)); - ++$active; - } - } - - if ($running > 0) { - $selected = curl_multi_select($multi, 0.5); - if ($selected === -1) { - usleep(1_000); - } - } - } - - unset($multi); - - $elapsed = max((hrtime(true) - $started) / 1_000_000_000, 0.000001); - - return [ - 'attempted' => $attempted, - 'successful' => $successful, - 'failed' => $failed, - 'timeouts' => $timeouts, - 'rpm' => ($successful / $elapsed) * 60, - 'latencies' => $latencies, - 'duplicates' => $duplicates, - 'elapsed_seconds' => $elapsed, - ]; -} - -/** - * @param array $serverRuntime - * @return array - */ -function uidEnvironment(string $release, array $serverRuntime): array -{ - $cpuModel = 'unknown'; - $cpuInfo = is_readable('/proc/cpuinfo') ? file_get_contents('/proc/cpuinfo') : false; - if (is_string($cpuInfo) && preg_match('/^model name\s*:\s*(.+)$/m', $cpuInfo, $matches) === 1) { - $cpuModel = trim($matches[1]); - } - - $extensions = get_loaded_extensions(); - sort($extensions, SORT_STRING); - - $environment = [ - 'stable' => true, - 'php_version' => PHP_VERSION, - 'php_sapi' => PHP_SAPI, - 'operating_system' => PHP_OS_FAMILY . ' ' . php_uname('r'), - 'cpu_model' => $cpuModel, - 'memory_limit' => (string) ini_get('memory_limit'), - 'opcache' => (string) ini_get('opcache.enable_cli'), - 'jit' => (string) ini_get('opcache.jit'), - 'xdebug' => extension_loaded('xdebug'), - 'extensions' => $extensions, - 'runner' => (string) (getenv('RUNNER_NAME') ?: 'github-actions'), - 'server_runtime' => $serverRuntime, - ]; - - $fingerprintSource = $environment; - $environment['fingerprint'] = hash( - 'sha256', - json_encode($fingerprintSource, JSON_THROW_ON_ERROR), - ); - $environment['release'] = $release; - - return $environment; -} - -/** - * @return array{ - * rpms:list, - * latencies:list, - * attempted:int, - * successful:int, - * failed:int, - * timeouts:int, - * duplicates:int, - * elapsed:float - * } - */ -function uidEmptyAggregate(): array -{ - return [ - 'rpms' => [], - 'latencies' => [], - 'attempted' => 0, - 'successful' => 0, - 'failed' => 0, - 'timeouts' => 0, - 'duplicates' => 0, - 'elapsed' => 0.0, - ]; -} - -/** - * @param array{ - * rpms:list, - * latencies:list, - * attempted:int, - * successful:int, - * failed:int, - * timeouts:int, - * duplicates:int, - * elapsed:float - * } $aggregate - * @param array{ - * attempted:int, - * successful:int, - * failed:int, - * timeouts:int, - * rpm:float, - * latencies:list, - * duplicates:int, - * elapsed_seconds:float - * } $result - */ -function uidAccumulate(array &$aggregate, array $result): void -{ - $aggregate['rpms'][] = $result['rpm']; - $aggregate['attempted'] += $result['attempted']; - $aggregate['successful'] += $result['successful']; - $aggregate['failed'] += $result['failed']; - $aggregate['timeouts'] += $result['timeouts']; - $aggregate['duplicates'] += $result['duplicates']; - $aggregate['elapsed'] += $result['elapsed_seconds']; - - $remaining = UID_MAX_LATENCY_SAMPLES - count($aggregate['latencies']); - if ($remaining > 0) { - $aggregate['latencies'] = [ - ...$aggregate['latencies'], - ...array_slice($result['latencies'], 0, $remaining), - ]; - } -} - -/** - * @param array{ - * rpms:list, - * latencies:list, - * attempted:int, - * successful:int, - * failed:int, - * timeouts:int, - * duplicates:int, - * elapsed:float - * } $aggregate - * @param array $serverRuntime - * @return array - */ -function uidBuildDocument( - string $release, - string $route, - int $concurrency, - int $duration, - int $repetitions, - int $idsPerResponse, - int $warmup, - array $aggregate, - array $serverRuntime, -): array { - // Balanced AB/BA trials always have an even sample count. - $sortedRpms = $aggregate['rpms']; - sort($sortedRpms, SORT_NUMERIC); - $middle = intdiv(count($sortedRpms), 2); - $medianRpm = ($sortedRpms[$middle - 1] + $sortedRpms[$middle]) / 2; - $spread = $medianRpm > 0 - ? ((uidPercentile($aggregate['rpms'], 0.75) - uidPercentile($aggregate['rpms'], 0.25)) / $medianRpm) * 100 - : 100.0; - $stable = $spread <= 15.0 - && $aggregate['failed'] === 0 - && $aggregate['duplicates'] === 0 - && $aggregate['timeouts'] === 0; - $latencies = $aggregate['latencies']; - - return [ - 'schema_version' => 1, - 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'environment' => uidEnvironment($release, $serverRuntime), - 'workloads' => [[ - 'name' => $route . '-c' . $concurrency, - 'type' => 'http', - 'metadata' => [ - 'route' => '/' . ltrim($route, '/'), - 'trial_duration_seconds' => $duration, - 'ids_per_response' => $idsPerResponse, - 'paired_trial_order' => 'AB/BA', - ], - 'repetitions' => $repetitions, - 'warmup_operations' => $warmup, - 'duration_seconds' => $duration * $repetitions, - 'concurrency' => $concurrency, - 'result' => [ - 'trial_successful_rpm' => $aggregate['rpms'], - 'attempted_operations' => $aggregate['attempted'], - 'successful_operations' => $aggregate['successful'], - 'failed_operations' => $aggregate['failed'], - 'timeouts' => $aggregate['timeouts'], - 'duplicate_ids' => $aggregate['duplicates'], - 'successful_rpm' => round($medianRpm, 5), - 'error_rate' => $aggregate['attempted'] === 0 - ? 0.0 - : $aggregate['failed'] / $aggregate['attempted'], - 'measured_elapsed_seconds' => round($aggregate['elapsed'], 5), - 'latency_ms' => [ - 'minimum' => $latencies === [] ? null : round(min($latencies), 5), - 'average' => $latencies === [] ? null : round(uidAverage($latencies), 5), - 'p50' => $latencies === [] ? null : round(uidPercentile($latencies, 0.50), 5), - 'p95' => $latencies === [] ? null : round(uidPercentile($latencies, 0.95), 5), - 'p99' => $latencies === [] ? null : round(uidPercentile($latencies, 0.99), 5), - 'maximum' => $latencies === [] ? null : round(max($latencies), 5), - ], - 'cpu' => [ - 'average_percent' => null, - 'peak_percent' => null, - ], - 'memory' => [ - 'average_mb' => null, - 'peak_mb' => null, - 'growth_mb' => null, - ], - 'stability' => [ - 'status' => $stable ? 'stable' : 'unstable', - 'spread_percent' => round($spread, 5), - ], - ], - ]], - ]; -} - -$urls = [ - 'baseline' => rtrim($baselineUrl, '/') . '/' . ltrim($route, '/'), - 'candidate' => rtrim($candidateUrl, '/') . '/' . ltrim($route, '/'), -]; - -/** @return array */ -function uidServerRuntime(string $url): array -{ - $handle = uidCreateHandle(rtrim($url, '/') . '/health'); - $body = curl_exec($handle); - $httpCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); - if (!is_string($body) || $httpCode !== 200) { - throw new RuntimeException('Unable to read benchmark server runtime'); - } - - $health = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $runtime = is_array($health) ? ($health['runtime'] ?? null) : null; - if (!is_array($runtime)) { - throw new RuntimeException('Benchmark server runtime is missing'); - } - - return $runtime; -} - -$serverRuntime = uidServerRuntime($baselineUrl); -if ($serverRuntime !== uidServerRuntime($candidateUrl)) { - throw new RuntimeException('Benchmark server runtimes do not match'); -} -if (($serverRuntime['opcache'] ?? false) !== true) { - throw new RuntimeException('Warm host benchmark requires OPcache on both servers'); -} - -foreach ($urls as $url) { - $warmupResult = uidRunOperations($url, $concurrency, $warmup, $idsPerResponse); - if ( - $warmupResult['failed'] !== 0 - || $warmupResult['timeouts'] !== 0 - || $warmupResult['duplicates'] !== 0 - ) { - throw new RuntimeException('Host benchmark warmup produced invalid responses'); - } -} - -$aggregates = [ - 'baseline' => uidEmptyAggregate(), - 'candidate' => uidEmptyAggregate(), -]; - -for ($repetition = 0; $repetition < $repetitions; ++$repetition) { - $order = ($repetition % 2) === 0 - ? ['baseline', 'candidate'] - : ['candidate', 'baseline']; - - foreach ($order as $target) { - $result = uidRunDuration( - $urls[$target], - $concurrency, - $duration, - $idsPerResponse, - ); - uidAccumulate($aggregates[$target], $result); - } -} - -$baselineDocument = uidBuildDocument( - '5.0', - $route, - $concurrency, - $duration, - $repetitions, - $idsPerResponse, - $warmup, - $aggregates['baseline'], - $serverRuntime, -); -$candidateDocument = uidBuildDocument( - 'candidate', - $route, - $concurrency, - $duration, - $repetitions, - $idsPerResponse, - $warmup, - $aggregates['candidate'], - $serverRuntime, -); - -foreach ([ - $baselineOutput => $baselineDocument, - $candidateOutput => $candidateDocument, -] as $path => $document) { - file_put_contents( - $path, - json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, - ); -} - -foreach ([$baselineDocument, $candidateDocument] as $document) { - $result = $document['workloads'][0]['result']; - if (($result['stability']['status'] ?? null) !== 'stable') { - throw new RuntimeException('Host benchmark did not reach a stable valid state'); - } -} diff --git a/benchmarks/release/RunwireProfile.php b/benchmarks/release/RunwireProfile.php deleted file mode 100644 index 14d2a5f..0000000 --- a/benchmarks/release/RunwireProfile.php +++ /dev/null @@ -1,96 +0,0 @@ - $operations, - 'elapsed_seconds' => round($elapsed, 6), - 'operations_per_second' => round($operations / max($elapsed, 0.000001), 3), - ]; -} - -$unboundProvider = new InMemorySequenceProvider(); -$unboundConfig = new SnowflakeConfig(sequenceProvider: $unboundProvider); -$unbound = uidMeasureBatch( - static function (int $count) use ($unboundConfig): void { - for ($index = 0; $index < $count; ++$index) { - Snowflake::generateWithConfig($unboundConfig); - } - }, -); - -$capabilities = new RuntimeCapabilities( - driver: RuntimeDriver::NATIVE, - runwireLoopAvailable: true, - supportsRunwireCoroutines: true, -); -$host = RuntimeContext::fromCapabilities($capabilities, 'uid-release-profile', concurrent: true); -$coroutines = new CoroutineRuntime(); -$boundProvider = new InMemorySequenceProvider(); - -$bound = uidMeasureBatch( - static function (int $count) use ($host, $coroutines, $boundProvider): void { - $request = RequestContext::create($host); - - $coroutines->runRequest( - $request, - static function (CoroutineScope $scope) use ($host, $request, $boundProvider, $count): void { - $config = new SnowflakeConfig( - sequenceProvider: $boundProvider, - runtime: new GenerationContext( - runwire: new RunwireBinding($host, $request, $scope), - ), - ); - - for ($index = 0; $index < $count; ++$index) { - Snowflake::generateWithConfig($config); - } - }, - ); - - $request->complete(); - }, -); - -file_put_contents( - $output, - json_encode([ - 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'unbound' => $unbound, - 'runwire_bound' => $bound, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, -); diff --git a/benchmarks/release/host-router.php b/benchmarks/release/host-router.php deleted file mode 100644 index 1dce164..0000000 --- a/benchmarks/release/host-router.php +++ /dev/null @@ -1,91 +0,0 @@ - 'release benchmark environment is incomplete'], JSON_THROW_ON_ERROR)); - - return; -} - -require_once $root . '/vendor/autoload.php'; - -header('Content-Type: application/json'); - -$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); - -try { - if ($path === '/health') { - $opcache = function_exists('opcache_get_status') ? opcache_get_status(false) : false; - $extensions = get_loaded_extensions(); - sort($extensions, SORT_STRING); - file_put_contents('php://output', json_encode([ - 'ok' => true, - 'runtime' => [ - 'php_version' => PHP_VERSION, - 'php_sapi' => PHP_SAPI, - 'memory_limit' => (string) ini_get('memory_limit'), - 'extensions' => $extensions, - 'opcache' => is_array($opcache) && ($opcache['opcache_enabled'] ?? false), - 'opcache_validate_timestamps' => (string) ini_get('opcache.validate_timestamps'), - 'opcache_optimization_level' => (string) ini_get('opcache.optimization_level'), - 'jit' => (string) ini_get('opcache.jit'), - ], - ], JSON_THROW_ON_ERROR)); - - return; - } - - if ($path === '/cuid2-one') { - file_put_contents('php://output', json_encode(['ids' => [CUID2::generate()]], JSON_THROW_ON_ERROR)); - - return; - } - - if ($path === '/cuid2-batch') { - $ids = []; - for ($index = 0; $index < 100; ++$index) { - $ids[] = CUID2::generate(); - } - - file_put_contents('php://output', json_encode(['ids' => $ids], JSON_THROW_ON_ERROR)); - - return; - } - - if ($path === '/snowflake-contended') { - $provider = new FilesystemSequenceProvider( - $stateDirectory, - 'release-host', - 2_000_000, - 1, - ); - $id = Snowflake::generateWithConfig(new SnowflakeConfig( - datacenterId: 1, - workerId: 1, - sequenceProvider: $provider, - )); - - file_put_contents('php://output', json_encode(['ids' => [$id]], JSON_THROW_ON_ERROR)); - - return; - } - - http_response_code(404); - file_put_contents('php://output', json_encode(['error' => 'unknown benchmark route'], JSON_THROW_ON_ERROR)); -} catch (Throwable $exception) { - http_response_code(500); - file_put_contents('php://output', json_encode([ - 'error' => $exception::class, - 'message' => $exception->getMessage(), - ], JSON_THROW_ON_ERROR)); -} diff --git a/benchmarks/release/soak-worker.php b/benchmarks/release/soak-worker.php deleted file mode 100644 index 973a47f..0000000 --- a/benchmarks/release/soak-worker.php +++ /dev/null @@ -1,153 +0,0 @@ -runRequest( - $request, - static function (CoroutineScope $scope) use ($host, $request, $provider, $remember): void { - $bound = new SnowflakeConfig( - datacenterId: 31, - workerId: 31, - sequenceProvider: $provider, - runtime: new GenerationContext( - runwire: new RunwireBinding($host, $request, $scope), - ), - ); - - for ($index = 0; $index < 5; ++$index) { - $remember(Snowflake::generateWithConfig($bound)); - } - }, - ); - $request->complete(); - - $cancelledRequest = RequestContext::create($host); - $cancelledRequest->cancel(CancellationReason::HOST_CANCELLED); - ++$cancellationChecks; - - try { - Snowflake::generateWithConfig(new SnowflakeConfig( - sequenceProvider: $provider, - runtime: new GenerationContext( - runwire: new RunwireBinding($host, $cancelledRequest), - ), - )); - ++$errors; - } catch (CancelledException) { - } - } - } catch (Throwable) { - ++$errors; - } - - ++$iterations; - usleep(500); -} - -file_put_contents( - $resultPath, - json_encode([ - 'status' => $errors === 0 && $duplicates === 0 ? 'passed' : 'failed', - 'iterations' => $iterations, - 'errors' => $errors, - 'duplicate_ids' => $duplicates, - 'cancellation_checks' => $cancellationChecks, - 'recent_id_window' => count($recentIds), - 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1_048_576, 5), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL, -); diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst index 59b4f57..af57ab5 100644 --- a/docs/benchmark-report.rst +++ b/docs/benchmark-report.rst @@ -62,23 +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. -Release Host Comparison ------------------------ - -The release acceptance workflow compares production-only, authoritative -autoloaders against tag ``5.0`` on one runner. Both HTTP servers enable OPcache -and disable JIT. Warm-up covers at least twice the configured worker count before -four balanced AB/BA trials of 60 seconds at each route and concurrency level. -The benchmark records each server's actual cache state and rejects mismatched -runtimes or disabled OPcache. Cold component profiles remain separate diagnostics. - -The host gate retains the 2% successful-RPM regression limit and requires stable -trials without response errors, timeouts or duplicate IDs within responses. Both -result files are retained even when a stability check fails. - -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/uid-review-and-release-plan.md b/docs/uid-review-and-release-plan.md deleted file mode 100644 index 99894c7..0000000 --- a/docs/uid-review-and-release-plan.md +++ /dev/null @@ -1,549 +0,0 @@ ---- -orphan: true ---- - -# UID 6.0 full improvement and release plan - -Review date: 2026-10-06. Reviewed revision: `322c9d9c033b16e4a47ff88c156ce230e3cef0fa`. -Latest local release tag: `5.0`, at `4a95eb8058e73c72e74e44fedd25755198899eae`. -Status: sections A–F implemented and regression-covered; section G, host performance, soak, and exact-final release acceptance remain open. - -## Current acceptance and simplification review (2026-10-07) - -Latest committed revision: `7721003b7306ff16f6f938af071e79ab81b05a0c`. -[Security & Standards run 37597332991](https://github.com/infocyph/UID/actions/runs/37597332991) -passed. [Release Acceptance run 37597332094](https://github.com/infocyph/UID/actions/runs/37597332094) -passed diagnostics, the five-minute soak and eleven of twelve host-performance -lanes. Snowflake contention at concurrency five failed: **222956.43 → 214712.95 -successful RPM, a 3.70% regression against the unchanged 2% budget**. -Earlier local or hosted results do not certify this revision or the working copy. - -The structural review follows PHPForge's existing engineering principles: -keep behavior in its cohesive owner, remove unnecessary forwarding, and retain -meaningful security, interoperability and lifecycle boundaries. Relative to -5.0, five production types were added and one removed (59 → 63 source files). The added types own: - -- Passed clock/wait policy (`GenerationContext`) and optional host-owned Runwire - adaptation (`RunwireBinding`). Neither starts workers or an event loop. -- Explicit stored formats (`SonyflakeFormat`, `RandflakeFormat`). These distinguish - legacy compatibility from upstream layouts without silently reinterpreting IDs. -- The upstream cipher (`Sparx64`). This owns the independently tested algorithm. - -The existing `BinaryUnpack` type was expanded for shared decoding; it is not a -new file. The source count is a review signal, not an architectural quality score. - -The working copy adds no production types or files. Filesystem allocation and -its lock lifetime stay together in `next()`, eliminating `allocateLocked()`. -Inode comparisons stay in handle verification, eliminating `assertSameFile()`. -Snowflake reads its epoch constant directly, eliminating `getStartTimeStamp()`. -Complete provider/domain preparation before sampling the allocation timestamp. -Keep fresh pathname metadata, owner/type checks, pre/post-open inode verification, -integer exhaustion checks, cancellation forwarding, bounded waits, explicit -unlocking and provider-owned history. Public contracts and gate budgets remain -unchanged. - -Selected-source sustained comparisons use production authoritative autoloaders, -64 CLI server children, OPcache enabled, JIT disabled, and four 60-second trials -per release in balanced AB/BA order. The PHP 8.4 servers run in matching -disposable containers (8.4.26); the benchmark client runs PHP 8.5.4. These are -local matched comparisons, not hosted certification. - -| Server runtime / concurrency | Tag 5.0 RPM | Working-copy RPM | Regression | 2% gate | -| --- | ---: | ---: | ---: | --- | -| PHP 8.4 / 5 | 454859.98712 | 445766.25900 | 1.99923677% | Pass, narrow margin | -| PHP 8.4 / 50 | 627388.07439 | 619860.29262 | 1.20% | Pass | -| PHP 8.5 / 50 | 656077.42157 | 644697.62155 | 1.73% | Pass | - -PHPForge result-contract validation and stable-environment comparison passed -all three pairs without changing thresholds. PHP 8.4 baseline/candidate trial -spreads were 1.29221%/0.80187% at concurrency five and 0.58974%/1.34755% at fifty; -PHP 8.5 concurrency-fifty spreads were 1.29381%/0.44432%. All report -zero failed responses, timeouts or within-response duplicates; single-ID -responses do not establish global HTTP uniqueness. CPU/RSS remain unreported -by this harness. The concurrency-five margin is less than 0.001 percentage point, -so independent hosted confirmation on the final commit is essential. - -A short PHP 8.5 concurrency-five diagnostic measured a 0.49% gain (four -15-second trials per release), with spreads of 8.26%/7.98%. This does not replace -a sustained comparison. The selected-source sustained PHP 8.5 concurrency-fifty -recheck passed at 1.73%; the prior source measured 3.18% on that local runtime. -The runs occurred separately and do not isolate the effect of individual edits. - -Local processors, detailed quality checks and the final release guard passed: -229 tests / 4,094 assertions. Strict documentation and clean production smoke -checks passed. Changes are uncommitted. Keep this plan until exact-final-commit -hosted confirmation and the resource/lifecycle acceptance requirements below -are complete. No release/tag is published. - -## Complete planned scope - -The requested scope includes every review finding and every previously listed -improvement. Enhancements are included delivery work; optional integrations and -format modes remain optional for consumers. Profiling work must finish with a -measured implementation decision, even when the correct decision is to retain -the existing algorithm. No item is left as an unspecified later wishlist. - -Target **6.0.0** for the complete scope. Correcting the public mixed-ID comparison -contract and freezing mutable epoch configuration can change observable behavior; -include those changes in a major release with migration coverage. The next -release requires **PHP 8.4 or newer on a 64-bit runtime**, as requested. Preserve -legacy stored ID formats and include the PHP minimum change in the migration guide. - -| Delivery area | Included changes | Implementation section | -| --- | --- | --- | -| Required safety fixes | R01/R02/R08: secure state and locks, authoritative allocation, loss/exhaustion handling | A | -| Required correctness fixes | R03–R07/R09–R11: generation state, counters, validation, lease retries, worker state, numeric codecs, comparison and GUIDs | B | -| Required release hygiene | R12–R14: tooling, support matrix, dependency metadata, docs and secret redaction | C | -| Included runtime enhancement | Passed Runwire 2.1.1 context/request/task instances, capability selection, cooperative waits and lifecycle-safe fallback | D | -| Included format enhancement | Explicit upstream-compatible Sonyflake/Randflake modes, legacy decoding and migration | E | -| Included configuration enhancement | Injected clocks, bounded waiting and immutable epoch normalization | F | -| Included performance work | CUID2/base-codec profiling and justified optimizations, production host benchmarks and soak | G and release gates | - -Implement A–C first, then F's time/configuration boundaries, D's runtime binding -and E's explicit formats. Complete G against the resulting common and bound paths. -Run final acceptance after all included changes are present. Keep each cohesive -change reviewable and regression-covered; do not fold unrelated repository cleanup -into this release. - -## Review scope and evidence - -The review covered all production generator families, configuration objects, -sequence providers, binary/base codecs, value objects, comparator, helpers, -tests, benchmark harnesses, Composer metadata, documentation and CI wrapper. -Graphify supplied navigation; findings below were verified in source and with -targeted PHP probes. Reflection was used only to reach otherwise impractical -counter boundaries, not as evidence that attackers can mutate private state. - -Final selected-source local evidence on 64-bit PHP 8.5.4: - -| Check | Result | -| --- | --- | -| PHPForge doctor and configuration inspection | Healthy; PHP 8.4/8.5 matrix and vendor-owned quality configuration | -| Processors, detailed checks and release guard | Passed; 229 tests / 4,094 assertions, including process/fork tests | -| Static analysis, taint analysis and complexity limits | Passed without suppressions or threshold changes | -| Dependency audit | Zero advisories; one non-blocking abandoned development dependency (`doctrine/annotations`) | -| Strict Sphinx build | Passed | -| Clean production smoke without optional peers | Passed; native generation, formats, values and codecs | - -The original review reproduced findings R01–R11 below. Their implementation and -regression coverage are recorded in sections A–F. Historical component timings -and green QA do not replace the sustained host and lifecycle requirements. The -next release's required runtime matrix begins at PHP 8.4. Exact-final-commit -hosted acceptance remains open for the uncommitted simplification changes. - -## Required findings - -Severity describes impact and prerequisites, rather than a claimed CVSS score. - -| ID | Priority | Finding and verified evidence | Owner | -| --- | --- | --- | --- | -| R01 | High, conditional local security | `FileLock::acquire()` opens predictable files with `fopen(..., 'c+')` and follows symlinks. A pre-existing `uid-test-1.seq` symlink caused its writable target to become `100,1`. The default shared temporary directory permits precreation attacks by another local user. Cache fallback locks share the opener and can be redirected or obstructed too. This is not a demonstrated remote attack. | `src/Support/FileLock.php:21`, filesystem/cache providers | -| R02 | High, conditional data integrity | PSR-16 state loss restarts allocation at 1 for the same timestamp. Clearing the cache between two calls on the same Randflake config produced identical IDs within one second. Distributed locking alone cannot recover evicted, expired, cleared or lost allocation state. A null TTL may use the backend's default lifetime. | `src/Sequence/PsrSimpleCacheSequenceProvider.php:114`, `src/Randflake.php:256`, provider docs | -| R03 | Medium, ordinary composition | Random ULID generation overwrites `lastRandChars` used by monotonic mode without updating its timestamp. Monotonic → random → monotonic at one timestamp can move backwards. A seeded boundary probe produced `...YYYYYYYYYYYYYYYZ` followed by a smaller random-derived tail. | `src/ULID.php:94` | -| R04 | Medium, extremely rare counter boundary | ULID overflow clears all tail digits before throwing. Calling again at the same explicit timestamp emits `01HF7YAT3V0000000000000001` instead of staying exhausted. This can reuse prior values. | `src/ULID.php:102`, `incrementRandomState()` | -| R05 | Medium, extremely rare counter boundary | UUIDv7 increments an 80-bit tail, then overwrites version and variant bits. Carrying into the variant bits made `018bcfe5-687b-7000-bfff-ffffffffffff` become the smaller `018bcfe5-687b-7000-8000-000000000000`. Version-bit carries have the same underlying problem. | `src/UUID.php:637`, `output()` | -| R06 | Medium, input boundary | ULID, NanoID and TBSL regexes use `$` without strict end-of-input matching and accept one trailing newline. ULID binary conversion silently discards it; TBSL validation and byte decoding disagree. | `src/ULID.php:169`, `src/NanoID.php:40`, `src/TBSL.php:87` | -| R07 | Medium, delayed/retried coordination | Randflake validates a lease before allocation, but resamples time after a provider timestamp exception without checking the lease/lifetime again. A delayed first callback followed by a retry generated an ID with a timestamp later than `leaseEnd`. | `src/Randflake.php:261` | -| R08 | Medium, boundary and operational reliability | Filesystem reservation arithmetic overflows intermediate integer expressions. Starting from `100,9223372036854775806`, size 1 returned the final integer but persisted `100,9.2233720368548E+18`; the next cached allocation raised `TypeError`. | `src/Sequence/FilesystemSequenceProvider.php:78` | -| R09 | Medium, persistent-worker stability | Filesystem `pathCache` is capped at 1,024, but `reservations` retained 1,050 keys after 1,050 domains, even with size 1. Sonyflake retains string-keyed static state after providers disappear; a probe released 250 provider/config domains and retained all 250 entries. `spl_object_id()` reuse can also transfer stale state to an unrelated provider. | `src/Sequence/FilesystemSequenceProvider.php:87`, `src/Sonyflake.php:34`, `generateInternal()` | -| R10 | Medium, public utility contracts | Snowflake/Sonyflake decode eight `ff` bytes to `18446744073709551615`, which their own validators reject. OpaqueId decodes the corresponding full unsigned token `LygHa16AHYF` to `-1`, outside its generation domain. | Numeric decoding in `src/Snowflake.php`, `src/Sonyflake.php`, `src/OpaqueId.php:30` | -| R11 | Medium/low, public utilities | Comparator relations form a cycle: `2 < 10`, `10 < 1a`, `1a < 2`. Sorting mixed numeric/text IDs has no consistent total order. Separately, `UUID::guid(false)` returns literal `\{...\}` on the PHP fallback path, and UUID normalization rejects it. | `src/IdComparator.php:15`, `src/UUID.php:146` | -| R12 | Required release gate | Three `markTestSkipped()` directives fail the strict scanner even though their fork prerequisites exist locally. Installed PHPForge configuration declares `dependency_tree` and `dependency_tree_types`, which installed cognitive-complexity 1.3.0 does not accept. `ic:active-config` also fails. Hosted QA is red. | Tests, PHPForge configuration/dependency pairing, CI | -| R13 | Required portability gate | Composer promises PHP 8.2+, but the reusable workflow currently resolves only 8.4/8.5. Production code also calls `ctype_digit()`/`ctype_xdigit()` without declaring `ext-ctype`. The current host provides ctype, so this is a metadata/support gap, not a reproduced host failure. | `composer.json`, PHPForge runtime matrix | -| R14 | Required documentation accuracy | UUID docs claim a v7 node argument and an `isValid` parse field; neither exists. `UUID::v7(null, $node)` silently ignores the extra positional argument. NanoID docs incorrectly describe customizable rejection sampling instead of its fixed Base64url construction. Sonyflake/Randflake references need to distinguish UID's formats from upstream wire compatibility. | `docs/uuid.rst`, `docs/random-ids.rst`, `docs/compatibility.rst`, references | - -### Format and security boundaries - -UID's Sonyflake field order is time/machine/sequence. The [upstream implementation](https://raw.githubusercontent.com/sony/sonyflake/master/sonyflake.go) -uses time/sequence/machine. With epoch `1577836800000`, upstream components -elapsed=1, sequence=1, machine=42 encode as `16842794`; UID decodes sequence=42, -machine=256. Current UID docs already describe its 39/16/8 order: preserve that -stored format and explicitly describe it as a UID variant. An upstream-compatible -mode is a separate feature, not a silent parser correction. - -UID Randflake uses its own eight-round Feistel permutation. [Upstream Randflake](https://github.com/gosuda/randflake) -uses SPARX64, different byte interpretation and signed decimal presentation. -The [upstream vector file](https://raw.githubusercontent.com/gosuda/randflake/main/test_vectors.json) -defines zero-key token `1qjeojjevu31n` as timestamp=1730000001, node=0, -sequence=0. UID decodes it as timestamp=1999128447, node=39131, -sequence=39141. This proves a compatibility difference, not a cryptanalytic -break. State explicitly that UID's custom permutation has no established -cryptographic security claim; retain the existing warning that inspection does -not authenticate an ID. Do not replace the permutation silently for stored IDs. - -The [TypeID 0.3 specification](https://raw.githubusercontent.com/jetify-com/typeid/main/spec/README.md) -allows user-supplied UUID variants while requiring v7 for newly generated IDs. -UID's permissive `fromUuid()` behavior fits that contract and should be preserved. -The [ULID specification](https://github.com/ulid/spec) and -[RFC 9562 section 6.2](https://datatracker.ietf.org/doc/html/rfc9562#section-6.2) -support the monotonicity/overflow acceptance cases for R03–R05. - -Positive observations: CSPRNG-backed generation is retained; RandomSampler -uses unbiased rejection sampling; bounded binary/base decoders and malformed -persisted-state rejection are already present; process-random ID families and -filesystem reservations include fork checks; PSR-16 distributed synchronizers -are explicit; loading helpers performs declarations rather than discovery/I/O. -No claim of cryptographic certification is made for a full-library code review. - -## Implementation sequence - -### A. Filesystem and authoritative allocation safety - -- [x] Reproduce R01 using private fixtures, then protect the existing lock/state - owner against symlinks, non-regular files, unsafe ownership and precreation. - Use an application-owned restricted directory where possible. Check file - identity and ownership on the opened handle; a path check alone leaves a race. - Never open an unverified target with truncating writes. -- [x] Preserve a stable lock inode while coordinating writers. Renaming a state - file underneath locks can let writers lock different inodes. -- [x] Default secure storage location remains unchanged; if an operator changes location, use the documented coordinated migration - that preserves sequence high-water marks. Mixed old/new paths or independent - empty stores must not create two allocation authorities for the same domain. -- [x] Fix R08 with integer-safe bounds checked before increment/reservation, - including exhaustion, maximum allocation, cached-next and write failures. -- [x] For R02, define shared allocation state as authoritative, non-expiring and - non-evicting while its timestamp can still be emitted. Require appropriately - durable storage and cross-host synchronization; generic PSR-16 cannot prove - those guarantees. Document backend default TTL, clearing, failover, restoration, - machine ownership and restart requirements. Fail closed when known state is - lost or allocations regress; a local guard alone is not a distributed fix. -- [x] Add repeated-allocation detection to Randflake's stable provider/domain - state so ordinary same-instance state loss cannot emit a known duplicate. - Cover restart/new-provider limitations explicitly. A durable external provider - can use the existing `SequenceProviderInterface`/callback boundary. -- [x] Preserve the current rule that the fallback cache lock coordinates only - cooperating processes on one host/filesystem. A Runwire mutex cannot replace - a distributed lock or an authoritative allocation store. - -Acceptance: adversarial link/precreation tests leave target files untouched; -counter exhaustion persists canonical state and yields domain exceptions; -state-loss probes emit no repeated IDs in the supported configuration; -cross-process allocation and migration produce zero duplicate IDs. Test lock -timeout, partial write, corrupt state, process termination and restart. `fflush()` -is not power-loss durability: document filesystem durability limits and use a -durable backend where that guarantee is required. - -### B. Generator, validation and utility correctness - -- [x] Separate ULID random-mode work from its monotonic state. Make overflow - state terminal for that timestamp; use a non-mutating overflow decision or - commit a new tail only after successful increment. Check timestamp range again - after any wait. Cover alternating modes and repeated calls after exceptions. -- [x] Increment only UUIDv7's usable 74 random bits, carrying across `rand_b` - and `rand_a` while keeping version/variant fixed. Define full exhaustion and - explicit timestamp behavior; preserve existing timestamp/output contracts. -- [x] Require exact end-of-input and protocol widths for R06. Align validation, - parse and byte conversion. Preserve intentionally supported UUID input forms; - do not turn every normalization helper into an unrelated strictness migration. -- [x] Revalidate Randflake lease, timestamp lifetime and rollback conditions on - every resampled retry before consuming another allocation. Retain UID's - documented inclusive lease end in a compatible release. -- [x] Reject decoded numeric IDs outside each family's signed/non-negative - domain. Test zero, maximum valid value, first invalid value, all-`ff` bytes and - every supported base, plus value-object construction. -- [x] Establish a total mixed-ID order for R11, for example numeric values first, - numeric comparison within that group, and lexical comparison within the text - group. Test transitivity and shuffled input permutations. Numeric-only and - text-only ordering remain stable. Review changes to previously ambiguous mixed - ordering against consumers before release; if its pairwise contract must be - preserved, add explicit modes and schedule the default correction for a major. -- [x] Correct the PHP GUID brace fallback and test normalization/round trips. -- [x] Keep provider-instance state weakly associated with the actual provider; - replace Sonyflake's reusable object-ID keys. Bound reservation/state metadata - without resetting live uniqueness or rollback guards. Do not retain empty - reservation bookkeeping for size 1 without a demonstrated need. -- [x] Define a bounded number of live configured domains for persistent workers. - Never blindly evict safety state and allow a previously used allocation domain - to restart. Verify fork, released providers, reused object IDs and request isolation. - -Acceptance: each reported defect has a regression that fails on the reviewed -revision and passes after remediation. Boundary tests use controlled state and -time; common-path randomness remains PHP's CSPRNG. Independent golden vectors -verify codecs and protocol envelopes rather than only self-round trips. - -### C. Toolchain, support contracts and documentation - -- [x] Resolve the cognitive-complexity/PHPForge configuration pairing in its - owning package. Do not edit `vendor/`, remove the requested checks, add baselines - or suppress errors. Refresh UID's development resolution once that fix is - available; verify the same detector and intended rules actually execute. -- [x] Replace skip directives with clear prerequisite assertions for the - designated process-test environment and supply `pcntl`/process support there. - Keep meaningful single-process coverage for other platforms. Any suite split - must be an explicit portability design; the process suite remains a required - release lane and is never hidden to satisfy the scanner. -- [x] Set Composer runtime requirements to `php: ^8.4` and `php-64bit: ^8.4` - for the next release. Update installation, requirements and compatibility docs - together. PHP 8.2/8.3 support ends with the 5.x line; document the upgrade path. -- [x] Verify production source and tooling on real PHP 8.4 and PHP 8.5 in stable - and lowest-compatible dependency lanes. Test subsequent supported PHP 8.x - versions as they become available; do not claim PHP 9 compatibility from a - lower-bound requirement alone. Require platform checks on clean production - installs and do not use Composer platform emulation as execution evidence. -- [x] Declare mandatory ctype support or remove that dependency with equivalent, - measured validation. Keep PSR-16 optional and production installs free of tooling. -- [x] Address abandoned dev-package usage through PHPForge/PHPBench ownership; - the remaining `doctrine/annotations` notice is transitive development tooling, - Composer audit passes, and no UID production dependency was added to mask it. -- [x] Fix R14 and publish explicit UID-specific Sonyflake/Randflake compatibility - notes. Include identifier selection, collision budgets for short configurable - outputs, unique storage constraints and independent authorization requirements. -- [x] Redact Randflake secret-bearing callable parameters with - `#[SensitiveParameter]`; consider configuration-object exposure separately. - Attribute redaction does not hide a public property or authorize logging it. - -Acceptance: strict full suite and release guard pass on the final source and -dependency set, no new suppression/skip directives, supported-runtime execution -and clean production installation evidence, accurate executable documentation. - -## D. Included Runwire 2.1.1 integration - -Include an integration focused on coordinated generators and blocking waits, -with representative host measurements as an acceptance gate. Installation and -binding remain optional for consumers. Runwire is not needed for correctness of -unbound generation and provides no clear throughput -advantage for individual UUID, ULID, NanoID, ObjectID, CUID2 or codec CPU operations. - -The local Runwire repository's exact `2.1.1` tag was inspected. It requires -PHP 8.4+, matching the next UID release's minimum. `RuntimeContext` exposes -capability/worker metadata, not an injectable distributed allocator or loop -handle. `RequestContext` provides runtime identity, completion, deadline and -cancellation. `CoroutineScope` provides cooperative sleep and local synchronization. - -### Proposed instance-based binding - -- [x] Use one small operation binding, provisionally `RunwireBinding`, constructed - from the host's `RuntimeContext`, optional `RequestContext` and optional - `CoroutineScope`. Let generation configs and coordination providers accept it - through additive instance APIs. Resolve feature support at binding time. -- [x] Reuse existing config/provider APIs and stable underlying sequence state. - Do not build a second wrapper hierarchy for every generator or clone allocation - authority whenever a request binding is created. -- [x] Forward the identical host context/scope references through framework → UID - and framework → another library → UID. Intermediaries may pass the binding, - config or provider instance; they must not discover a different global runtime. -- [x] Bind after worker creation/fork. Validate PID, runtime/request identity and - completed request state; reject stale/cancelled bindings. Do not retain a request - binding in static provider selectors or worker-wide mutable globals. -- [x] Use public 2.1.1 APIs only. In particular, scope has no public `closed()` - accessor: its public `hasLocal(TaskLocal)` checks scope openness before querying - the scheduler. A private library-owned key can validate a passed active scope - without installing host task-local state. Verify use within the active scheduler - and cover closed scopes before allocation; do not depend on private internals. -- [x] Use `RequestContext::cancellation` and `CoroutineScope::cancellation()` - together; cancellation or deadline expiry in either stops new allocation. - Check after every cooperative suspension and immediately before mutation. -- [x] With an active scope and coroutine support, retry `flock(LOCK_EX | LOCK_NB)` - with bounded `scope->sleep()` rather than blocking the event loop. Locks are - not socket readiness: do not register a lock file as an async writable stream. -- [x] Apply the same bounded cooperative strategy to configured clock-rollover - waits. Use `hrtime()`/Runwire deadlines for wait budgets and wall time for ID - timestamps and leases. Never derive a Unix ID timestamp from a monotonic clock. -- [x] Re-read/revalidate mutable reservation and sequence state after suspension. - Keep critical state mutation free of yields. A yielding remote provider needs - its own serialization/atomicity contract; merely passing a scope cannot supply it. -- [x] Release only UID-owned handles in `finally`. Never start/stop a runtime, - spawn a worker pool, take over an event loop, complete the host request, close - the host scope or cancel unrelated host tasks. -- [x] A committed allocation remains consumed if cancellation arrives afterward; - gaps are acceptable. Do not recycle allocations or retry a possibly committed - remote write as though it had not happened. -- [x] With no Runwire or no cooperative capability, use the normal synchronous - path and its configured limits. Missing capability is a fallback condition; - cancellation, corruption, failed authoritative storage and closed/stale scope - are terminal errors. Preserve the selected authoritative sequence provider. -- [x] Suggest Runwire to consumers and use it in PHP 8.4+ test fixtures. Keep it - optional at runtime and test clean supported-PHP installs without Runwire. - -Acceptance matrix: direct and intermediary instance forwarding; no Runwire -installed; present but no coroutine scope/capability; active scope with contended -lock; pre-cancelled and expired request/task; cancellation while waiting and -immediately before state mutation; cancellation after allocation commit; mismatched -runtime/PID; completed request; closed scope; fork and worker replacement; no -host-loop blocking; no lifecycle ownership changes; concurrent requests sharing -one authoritative provider without request-state leakage or duplicate IDs. - -A host worker slot/generation is useful lifecycle metadata, not a globally unique -Snowflake/Sonyflake node lease. Preserve explicitly coordinated node/machine IDs. -Do not automatically select the process-memory provider for persistent runtimes. - -## E. Included upstream-compatible formats and migration - -- [x] Add explicit Sonyflake format selection to generation configuration and - parsing/value APIs. Keep the existing UID time/machine/sequence format readable - and selectable; add upstream time/sequence/machine behavior as a separate mode. - Keep numeric storage and epoch units explicit at both generation and parsing. -- [x] Define epoch behavior for each mode and require the same epoch on both - sides of an interoperability test. Never infer an epoch from an unlabelled ID - or reuse a custom epoch merely because its integer happens to fit. -- [x] Add explicit Randflake format selection. Preserve decoding and generation - for the current UID Feistel format and add the upstream SPARX64 format with its - exact byte order, signed decimal representation and base32hex contract. - Do not approximate the cipher or substitute a faster custom permutation. -- [x] Pin the upstream reference revision used for each implementation and its - golden vectors. Validate independent encoding, decoding and generation cases, - including the upper timestamp range and negative upstream decimal values. -- [x] Make Randflake lease-end semantics explicit per format. Preserve inclusive - `leaseEnd` for UID legacy mode; use a clearly named exclusive boundary for the - upstream contract. Document the translation from an inclusive end to an - exclusive end and validate lifetime/overflow limits during conversion. -- [x] Include format identity in relevant configuration and coordination-domain - keys where its semantics differ. Share the same authoritative store when - multiple requests/workers generate within the same configured domain. -- [x] Carry format and epoch metadata in parsed/value representations where needed - for reliable round trips. Raw stored IDs need an external format discriminator: - the same bytes can be valid in multiple formats. Do not guess which permutation - or bit layout produced an unlabelled value. -- [x] Keep legacy format defaults unless the major-release migration explicitly - changes one. A new upstream mode must not silently reinterpret old stored values. - Document that changing format does not preserve cross-format uniqueness in one - unlabelled integer namespace; use appropriate storage keys/constraints. -- [x] Provide executable migration examples: retain old rows with legacy metadata, - enable explicit dual-format reads, select the desired format for new writes, - and coordinate writers before changing domains. IDs used as references must not - be rewritten without a consumer-owned transactional relationship migration. -- [x] Retain clear identifier/authentication boundaries for both modes. Upstream - compatibility is not authentication or independent cryptographic certification. - Mark the legacy custom permutation as obfuscation with no established security - claim; redact secret-bearing parameters for both implementations. -- [x] Keep implementations owned by their existing generator/codec boundaries. - Add a runtime dependency only if it provides substantial verified value and - supports the package baseline; a format enhancement does not justify a generic - cryptography framework or host-specific allocator infrastructure. - -Acceptance: independent pinned upstream vectors pass alongside unchanged legacy -vectors; explicit mode and epoch round trips work across numeric, binary and text -representations; wrong/missing metadata fails as documented; dual-format consumer -fixtures retain existing identifiers and relationships. Required allocation, -clock, cancellation and worker tests execute for both modes where applicable. - -## F. Included clocks, bounded waits and immutable configuration - -- [x] Add instance/configuration injection of PSR-20 `ClockInterface` where - generation needs a controllable wall clock. Keep explicit timestamp inputs - usable without another clock abstraction and retain the native fast path when - no clock is supplied. Keep `psr/clock` optional for consumers that use injection; - include development fixtures compatible with the PHP 8.4 minimum. -- [x] Preserve public parameter names and add clock/configuration options at real - existing API boundaries. Never store a request/tenant clock in static globals or - resolve it repeatedly through a container or runtime singleton. -- [x] Read wall time once per logical allocation attempt, then deliberately - resample after a retry or rollover wait. Revalidate epoch/lifetime, lease and - rollback conditions for that sample. Pass the computed value through hot - internal work rather than allocating a date object per bit/codec operation. -- [x] Keep monotonic timeout accounting separate from injected wall time. A frozen - test clock must not disable lock/deadline exhaustion or cause an infinite loop. - Do not substitute Runwire request start time for actual ID generation time. -- [x] Add explicit bounded wait/retry policy for coordinated generators and lock - acquisition. Cap attempts or elapsed monotonic time, and define a domain failure - when the budget is exhausted. Combine library limits with the earliest active - host request/task deadline; retain those limits on synchronous fallback. -- [x] Replace TBSL's tight rollback/rollover spin with bounded waiting. Use the - passed scope's cooperative sleep where available and a bounded native wait - otherwise. Measure short normal rollover behavior before selecting intervals. -- [x] Normalize custom epochs at configuration construction into immutable scalar - milliseconds or immutable date values, and reuse the normalized result. - Mutating a caller-owned `DateTime` later must not change an existing ID domain. - Validate supported epoch/range boundaries and preserve parser metadata. -- [x] Document the epoch behavior change in the 6.0 migration: construct a new - config to change domains, retain the old epoch to parse existing IDs, and avoid - switching a live generator domain by modifying a shared date object. -- [x] Use deterministic injected-clock tests for lease boundaries, retry resamples, - rollback, forward jumps, tick rollover, frozen clocks and request cancellation. - Retain controlled private-state probes only for unreachable counter boundaries. - -Acceptance: independent configs/clocks never contaminate one another; mutation of -the original date leaves the configured epoch unchanged; frozen clocks exhaust -their wait budget; cancellation stops before the next allocation mutation; clock -injection and native generation produce equivalent valid timestamps/formats. -Measure clock/date conversion overhead in both isolated and host benchmarks. - -## G. Included CUID2 and codec performance work - -- [ ] Profile CUID2 generation and fingerprint creation separately, including - cold initialization, warm calls, configured lengths and fork reseeding. Measure - SHA3 hashing, Base36 conversion and temporary allocation costs before editing. -- [ ] Profile BaseEncoder, TypeIdCodec and numeric conversions across 8, 10, 12, - 16, 20 and 32 bytes, all supported bases, zero/high-bit/max values, and valid, - invalid and oversized input. Retain a realistic large-input bound test. -- [ ] Implement measured reductions in repeated conversion, copying, callbacks or - temporary arrays inside the existing cohesive owners. Consider a direct bit - codec only for a demonstrated hot compatible base/width; preserve each format's - padding, alphabet, leading-zero and canonical-input behavior. -- [ ] Reuse common logic only when it remains simpler and improves or preserves - sustained host RPM. Generic Base32 and TypeID/Crockford alphabets and padding - are distinct contracts; do not merge them solely because their loops look alike. -- [ ] Preserve CUID2 entropy, digest choice, output distribution/length and fork - safety. Never replace CSPRNG work or weaken identifier security to win a benchmark. -- [ ] Record before/after component measurements and representative request RPM - under matching environments. Keep an optimization only when its measured value - justifies complexity within the release budgets. If no useful candidate wins, - close the profiling item with the measured decision to retain the current code. - -Acceptance: golden vectors and adversarial bounds remain correct, supported modes -produce equivalent valid output, and the selected implementation meets stable host -RPM/resource budgets. Deliver the profiling decision and reproducible measurements; -do not assert an improvement solely from historical microsecond timings. - -## Performance and release gates - -- [ ] Measure corrected code against tag `5.0` with matching runtimes, dependencies, - hardware and deployment configuration. Separate pure-generator, filesystem, - reservation, PSR-16 and optional Runwire-bound workloads. -- [ ] Use representative host routes generating one ID and batches of 100 IDs, - plus contended sequence allocation. Compare at least three warmed sustained - trials at concurrency 1, 5, 20 and 50, extending the curve if necessary to find - saturation. Use at least 60 seconds per measured trial after warm-up. -- [ ] Count only valid successful responses; collect successful RPS/RPM, p50/p95/ - p99, errors/timeouts, output/duplicate failures, CPU, peak/steady RSS, worker - count, lock wait and queue growth. Record extension and OPcache configuration. -- [ ] Use a default maximum 2% median successful-RPM regression on stable - comparable environments. Record variance; noisy results are inconclusive. - Bound errors, timeouts and invalid/duplicate IDs at zero in the accepted - supported workload; queues must not grow progressively. Set workload-specific - p99, wait and memory ceilings before measurement and explain capacity choices. -- [ ] Run component benchmarks and the existing contention matrix as supporting - diagnostics. They do not establish host application throughput. -- [ ] Run at least a five-minute persistent-worker soak with repeated requests, - changing/released configs/providers, cancellations, contention and worker - replacement. Sample in-load process RSS and lifecycle logs; do not mistake a - replacement PID for evidence that the old worker retained bounded memory. -- [ ] Establish unbound behavior/performance first. Report Runwire-bound results - separately and verify useful measured scheduling or throughput behavior within - the same correctness and resource budgets. Resolve a failing result within - this scope or report an explicit acceptance blocker; do not silently defer - the included integration or claim a gain that measurements do not support. -- [ ] Run exact-final-commit hosted QA, stable/lowest dependency lanes, static/ - security checks, supported runtimes, process coordination, documentation checks, - clean `--no-dev` installation, release guard and relevant host acceptance. -- [ ] Tag only after applicable required gates pass. Keep implementation readiness, - CI readiness, performance certification and published release/tag status separate. - -## Version recommendation and scope - -Target **6.0.0** for all sections A–G and the final gates. The expanded plan -includes previously optional delivery work and public behavior corrections. -Keep consumer adoption of Runwire, injected clocks and upstream-compatible -formats explicit. Preserve existing stored-ID decoding and named arguments. -Raise the production minimum to PHP 8.4 as requested; keep Runwire optional -despite the aligned PHP requirement. - -- [x] Publish a 5.x → 6.0 migration guide covering mixed-ID ordering, immutable - epochs, secure sequence-state locations, wait budgets, explicit format/lease - selection, the PHP 8.4 minimum, optional dependency installation and - passed-instance composition. -- [x] Inventory public call signatures and defaults against tag `5.0`; verify - positional and named argument use, helpers, facade calls and value objects. - Document every intentional major change and keep unrelated contracts stable. -- [x] Add consumer fixtures for old stored IDs, old configs/helpers, direct and - intermediary Runwire forwarding, and applications without optional packages. -- [ ] Require completion or an explicit measured acceptance decision for every - included item. A release is blocked while any required implementation, - compatibility, quality, integration or performance gate remains unresolved. - -A **5.0.1** security/correctness hotfix can precede 6.0 if urgent fixes must ship -earlier. It is a scoped subset and does not replace completion of this full plan. -Such a separate 5.x hotfix would preserve that line's existing PHP requirement. -Do not ship the PHP minimum increase or other intentional breaking public -behavior under a 5.x patch/minor version. The requested complete next release -targets **6.0.0 with PHP 8.4+**. diff --git a/src/Snowflake.php b/src/Snowflake.php index 2e582a6..0a17b0d 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -86,7 +86,7 @@ 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, @@ -388,6 +388,8 @@ private static function providerState( /** @var \ArrayObject $state */ $state = new \ArrayObject(); self::$lastStateByProvider[$provider] = $state; + + return $state; } if (!isset($state[$stateKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) { diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 026fe6e..ad61687 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -39,12 +39,6 @@ public static function acquire( $deadline = self::lockDeadline($timeoutMicros, $runtime); while (hrtime(true) < $deadline) { - if ($runtime !== null) { - $runtime->sleepMicroseconds(1_000); - } else { - usleep(1_000); - } - $wouldBlock = 0; if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { return $handle; @@ -52,6 +46,12 @@ public static function acquire( if ($wouldBlock !== 1) { throw new FileLockException($lockErrorMessage); } + + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } } catch (\Throwable $exception) { fclose($handle); @@ -162,10 +162,11 @@ private static function verifyHandle(string $path, $handle, ?array $before, stri throw new FileLockException($errorMessage); } - self::assertSafeMetadata($after, $errorMessage, $ownerId); - self::assertSafeMetadata($pathState, $errorMessage, $ownerId); if ( - $after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino'] + ($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); diff --git a/tests/HostBenchmarkAcceptanceTest.php b/tests/HostBenchmarkAcceptanceTest.php deleted file mode 100644 index 9f279c5..0000000 --- a/tests/HostBenchmarkAcceptanceTest.php +++ /dev/null @@ -1,26 +0,0 @@ -toBeTrue() - ->and(uidValidId('x', '/cuid2-one'))->toBeFalse() - ->and(uidValidId(str_repeat('0', 24), '/cuid2-batch'))->toBeFalse() - ->and(uidValidId('a' . str_repeat('0', 23) . "\n", '/cuid2-one'))->toBeFalse() - ->and(uidValidId((string) PHP_INT_MAX, '/snowflake-contended'))->toBeTrue() - ->and(uidValidId('9223372036854775808', '/snowflake-contended'))->toBeFalse() - ->and(uidValidId('-1', '/snowflake-contended'))->toBeFalse() - ->and(uidValidId('1', '/unrecognized'))->toBeFalse(); -}); - -test('host report computes an even-sample median and retains trial order outside workload metadata', function (): void { - $aggregate = uidEmptyAggregate(); - $aggregate['rpms'] = [400.0, 100.0, 300.0, 200.0]; - $document = uidBuildDocument('candidate', 'cuid2-one', 1, 60, 4, 1, 130, $aggregate, []); - $workload = $document['workloads'][0]; - expect($workload['result']['successful_rpm'])->toBe(250.0) - ->and($workload['result']['trial_successful_rpm'])->toBe([400.0, 100.0, 300.0, 200.0]) - ->and(array_key_exists('trial_successful_rpm', $workload['metadata']))->toBeFalse(); -}); diff --git a/tests/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php index 07b3975..7cb92e4 100644 --- a/tests/RuntimeIntegrationTest.php +++ b/tests/RuntimeIntegrationTest.php @@ -252,3 +252,48 @@ function (CoroutineScope $scope) use (&$capturedScope): void { 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/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); +} From 141af40a288d57101803c0b3d15ee972fd80b6e4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:26:55 +0600 Subject: [PATCH 093/107] docs(uid): align encoding and format references with UID 6 --- README.md | 2 +- docs/base-encoding.rst | 3 +++ docs/randflake.rst | 8 ++++---- docs/snowflake.rst | 2 +- docs/sonyflake.rst | 2 +- docs/tbsl.rst | 2 +- docs/ulid.rst | 2 +- docs/uuid.rst | 2 +- 8 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b062625..0b1de84 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 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) ## Requirements diff --git a/docs/base-encoding.rst b/docs/base-encoding.rst index a92daf9..441428d 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 @@ -56,5 +57,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/randflake.rst b/docs/randflake.rst index be2751c..a62cfc1 100644 --- a/docs/randflake.rst +++ b/docs/randflake.rst @@ -117,11 +117,11 @@ Validation and Parsing Binary and Alternate Bases -------------------------- -- ``Randflake::toBytes($id)`` / ``Randflake::fromBytes($bytes)`` -- ``Randflake::toBase($id, $base)`` / ``Randflake::fromBase($encoded, $base)`` -- ``Randflake::encodeString($id)`` / ``Randflake::decodeString($stringId)`` +- ``Randflake::toBytes($id, $format)`` / ``Randflake::fromBytes($bytes, $format)`` +- ``Randflake::toBase($id, $base, $format)`` / ``Randflake::fromBase($encoded, $base, $format)`` +- ``Randflake::encodeString($id, $format)`` / ``Randflake::decodeString($stringId, $format)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. Pass the same explicit +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. Pass the same explicit format to conversion/parsing APIs that was used to generate the ID. In upstream mode base 32 follows the upstream Base32hex representation. diff --git a/docs/snowflake.rst b/docs/snowflake.rst index 87c324a..1f32976 100644 --- a/docs/snowflake.rst +++ b/docs/snowflake.rst @@ -86,7 +86,7 @@ Binary and Alternate Bases - ``Snowflake::toBytes($id)`` / ``Snowflake::fromBytes($bytes)`` - ``Snowflake::toBase($id, $base)`` / ``Snowflake::fromBase($encoded, $base)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. Exception Types --------------- diff --git a/docs/sonyflake.rst b/docs/sonyflake.rst index 62f7967..17951ec 100644 --- a/docs/sonyflake.rst +++ b/docs/sonyflake.rst @@ -88,7 +88,7 @@ Binary and Alternate Bases - ``Sonyflake::toBytes($id)`` / ``Sonyflake::fromBytes($bytes)`` - ``Sonyflake::toBase($id, $base)`` / ``Sonyflake::fromBase($encoded, $base)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. Exception Types --------------- diff --git a/docs/tbsl.rst b/docs/tbsl.rst index 3fb8c02..a1f5435 100644 --- a/docs/tbsl.rst +++ b/docs/tbsl.rst @@ -72,7 +72,7 @@ Binary and Alternate Bases - ``TBSL::toBytes($id)`` / ``TBSL::fromBytes($bytes)`` - ``TBSL::toBase($id, $base)`` / ``TBSL::fromBase($encoded, $base)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. Exception Type -------------- diff --git a/docs/ulid.rst b/docs/ulid.rst index 0e63f7e..89e2a9a 100644 --- a/docs/ulid.rst +++ b/docs/ulid.rst @@ -44,7 +44,7 @@ Binary and Alternate Bases - ``ULID::toBytes($ulid)`` / ``ULID::fromBytes($bytes)`` - ``ULID::toBase($ulid, $base)`` / ``ULID::fromBase($encoded, $base)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. Exception Type -------------- diff --git a/docs/uuid.rst b/docs/uuid.rst index 5320429..755ade1 100644 --- a/docs/uuid.rst +++ b/docs/uuid.rst @@ -101,7 +101,7 @@ Binary and Alternate Bases - ``UUID::toBytes($uuid)`` / ``UUID::fromBytes($bytes)`` - ``UUID::toBase($uuid, $base)`` / ``UUID::fromBase($encoded, $base)`` -Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. +Supported bases: ``10``, ``16``, ``32``, ``36``, ``58``, ``62``. GUID Helper ----------- From 61a2e2d4c492943b48897478388eb4aee49a0f5d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:28:25 +0600 Subject: [PATCH 094/107] docs(uid): sync value and storage semantics --- docs/id-facade.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/id-facade.rst b/docs/id-facade.rst index b5cd3cc..c9a82f2 100644 --- a/docs/id-facade.rst +++ b/docs/id-facade.rst @@ -10,7 +10,9 @@ Generation methods include ``uuid1`` through ``uuid8``, ``ulid``, ``typeId``, ``snowflakeValue`` and ``sonyflakeValue`` are the two configuration-aware value factories. They preserve a configured custom epoch so timestamps are interpreted -in the same ID domain in which they were generated. +in the same ID domain in which they were generated. ``sonyflakeValue`` also +preserves the configured ``SonyflakeFormat`` so parsing uses the original bit +layout. .. code-block:: php From 729c4cf806b36563b1f4b75cb1c041ba5920eab9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:28:28 +0600 Subject: [PATCH 095/107] docs(uid): sync value and storage semantics --- docs/value-objects.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/value-objects.rst b/docs/value-objects.rst index 5ebb92f..76f6764 100644 --- a/docs/value-objects.rst +++ b/docs/value-objects.rst @@ -13,5 +13,6 @@ factory so generated values are parsed in their original epoch/format domain. ``UuidValue::isSortable()`` is true only for UUIDv6 and UUIDv7. A generic UUIDv8 value does not infer timestamp or sortable semantics. -``IdComparator`` compares digit-only IDs as unsigned decimal values and otherwise -uses lexical ordering. +``IdComparator`` defines a total mixed order: digit-only IDs compare as unsigned +decimal values, textual IDs compare lexically, and numeric IDs sort before text +when the two categories are mixed. From 348bd2decfbae6f8afe5af968e80091fb4502f71 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:28:31 +0600 Subject: [PATCH 096/107] docs(uid): sync value and storage semantics --- docs/db-storage.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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. From 06fd5796263610538cef2537fe9eceb9ae7950ea Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:28:34 +0600 Subject: [PATCH 097/107] docs(uid): sync value and storage semantics --- docs/exceptions.rst | 2 ++ 1 file changed, 2 insertions(+) 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 ------------- From 64bd29d67eeb72628ed02e4541678884fd15738e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:29:04 +0600 Subject: [PATCH 098/107] docs(tbsl): align specification with 60-bit payload --- TBSL.md | 54 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/TBSL.md b/TBSL.md index 3b742ec..5fc3bc7 100644 --- a/TBSL.md +++ b/TBSL.md @@ -23,16 +23,18 @@ 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 | Entropy or sequence | Random suffix by default, or a zero-based sequence suffix when sequenced mode is enabled. | -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 @@ -44,29 +46,30 @@ Generation accepts: 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: obtains a positive provider allocation from + `(type = "tbsl", machineId, timestamp)`, then encodes + `allocation - 1` as five hexadecimal characters. 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. From b0cbf1138408f069eff0280864e0cec63bb2fb1e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:29:27 +0600 Subject: [PATCH 099/107] docs(runtime): align optional integration requirements --- docs/framework-integration.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index c3dca5d..6b6e4b1 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -22,8 +22,8 @@ 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. -Runwire 2.1 Integration ------------------------ +Runwire 2.1.1+ Integration +------------------------- 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. @@ -58,7 +58,8 @@ operation. Construct bindings after worker creation/fork and do not retain a request binding in static provider selectors or worker-wide mutable state. The binding validates the process, request/runtime identity, request completion, active coroutine -capability, cancellation and deadlines using Runwire 2.1 public APIs. +capability, cancellation and deadlines using Runwire 2.1.1 public APIs. UID's +development fixture targets ``infocyph/runwire ^2.1.1``. A CoroutineScope does not expose public request identity. Therefore the host boundary that owns both objects is responsible for pairing the correct scope with @@ -80,7 +81,7 @@ failures remain terminal. PSR-20 Clock Injection ---------------------- -GenerationContext optionally accepts Psr\\Clock\\ClockInterface for a +``GenerationContext`` optionally accepts ``Psr\\Clock\\ClockInterface`` for a controllable wall clock. Monotonic wait accounting remains based on hrtime() and Runwire deadlines, so a frozen test clock cannot create an infinite lock or rollover wait. Explicit timestamp APIs continue to work without a clock object. From a47a8c59cf70e7a7e4111999b74fbfd960bc4b68 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:29:31 +0600 Subject: [PATCH 100/107] docs(runtime): align optional integration requirements --- docs/installation.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/installation.rst b/docs/installation.rst index bd69201..2b698ad 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -21,3 +21,19 @@ Install The package autoloads namespaced generator functions from ``src/functions.php``. Class APIs remain the preferred entry point for parsing, validation, and conversion. + +Optional Integrations +--------------------- + +UID has no mandatory Runwire, PSR-20 clock, or PSR-16 cache dependency. Install +only the integration used by the application: + +.. code-block:: bash + + composer require infocyph/runwire:^2.1.1 + composer require psr/clock:^1.0 + composer require psr/simple-cache:^3.0 + +Runwire enables request-aware cooperative waits, PSR-20 enables injectable wall +clocks through ``GenerationContext``, and PSR-16 is required only for the +simple-cache sequence provider. From 000fe743351433b5f510b00658038d4744c4ea80 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:29:34 +0600 Subject: [PATCH 101/107] docs(runtime): align optional integration requirements --- docs/compatibility.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 220ecd5..c3a8b84 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -13,7 +13,8 @@ Format Compatibility - 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. + 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. @@ -65,6 +66,6 @@ Runtime Requirements - PHP 8.4 or newer on a 64-bit runtime. - The ctype extension is required. -- Runwire 2.1 and PSR-20 clocks are optional passed-instance integrations. +- 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. From b5055ffb5e936cc9d513d9d2a01a756b53b0baad Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:29:38 +0600 Subject: [PATCH 102/107] docs(runtime): align optional integration requirements --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index f6d5980..69e2b6d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ It supports: - TBSL - NanoID and CUID2 - KSUID and XID -- RandomId and NanoID +- RandomId - Opaque and deterministic IDs .. toctree:: From 35e3ff67d57ea24f0fd79682afddb72ec2a08cf1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:31:14 +0600 Subject: [PATCH 103/107] docs(uid): finish public capability synchronization --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b1de84..9c1b0a2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ All-in-one unique ID toolkit for PHP. - Value objects and comparator utilities - 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 @@ -76,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 From ab3b263bc076cf73a2719392a47ee162175c2d2b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:31:18 +0600 Subject: [PATCH 104/107] docs(uid): finish public capability synchronization --- TBSL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TBSL.md b/TBSL.md index 5fc3bc7..fac9aa6 100644 --- a/TBSL.md +++ b/TBSL.md @@ -24,7 +24,7 @@ Character positions are 1-based. | Characters | Length | Field | Description | |:--|--:|:--|:--| | 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 | Entropy or sequence | Random suffix by default, or a zero-based sequence suffix when sequenced mode is enabled. | +| 16-20 | 5 hex chars | Sequence or entropy | Zero-based sequence suffix by default, or random entropy when sequenced mode is disabled. | The time-machine payload is numeric, not a fixed-width decimal string. Parsing is: @@ -41,7 +41,7 @@ digits, as long as the combined value still fits the 60-bit field. 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: @@ -50,10 +50,10 @@ The generator: 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: obtains a positive provider allocation from + - sequenced mode (default): obtains a positive provider allocation from `(type = "tbsl", machineId, timestamp)`, then encodes - `allocation - 1` as five hexadecimal characters. + `allocation - 1` as five hexadecimal characters; + - random mode: first 5 hex characters from 3 random bytes. 6. Returns the 20-character uppercase hexadecimal string. In sequenced mode, provider allocations `1..0x100000` map to encoded suffixes From 4e509b4e2297803b710ee4a06a35935d8ef99365 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:31:23 +0600 Subject: [PATCH 105/107] docs(uid): finish public capability synchronization --- docs/tbsl.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tbsl.rst b/docs/tbsl.rst index a1f5435..e998d3f 100644 --- a/docs/tbsl.rst +++ b/docs/tbsl.rst @@ -3,7 +3,7 @@ TBSL Class: ``Infocyph\\UID\\TBSL`` -TBSL is a project-specific, time-based, lexicographically sortable uppercase hex ID. +TBSL is a project-specific, time-based, lexicographically sortable uppercase hex ID. ``TBSL::generate()`` uses sequenced mode by default; ``generateRandom()`` selects the entropy suffix. Format ------ From 0db4caf451743405b3fae3b6b34d48b46fdfb770 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:35:04 +0600 Subject: [PATCH 106/107] docs(uid): finish structural documentation audit --- docs/framework-integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index 6b6e4b1..5f930b2 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -23,7 +23,7 @@ runtime policy. SnowflakeConfig, SonyflakeConfig, RandflakeConfig and TBSLConfig may receive a GenerationContext without changing the ordinary synchronous APIs. Runwire 2.1.1+ Integration -------------------------- +-------------------------- 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. From 57fc4ef851d44daaeb24658a94d3c7f8a5a3d1b1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 7 Oct 2026 18:35:08 +0600 Subject: [PATCH 107/107] docs(uid): finish structural documentation audit --- docs/base-encoding.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/base-encoding.rst b/docs/base-encoding.rst index 441428d..be78b3d 100644 --- a/docs/base-encoding.rst +++ b/docs/base-encoding.rst @@ -24,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