feat(idempotency): univeros/idempotency — storage contract + adapters (#172)#177
Merged
Merged
Conversation
…#172) Adds the storage layer the rest of the Idempotency-Key epic builds on: a small IdempotencyStoreInterface + a StoredResponse value object + three adapters covering the realistic deployment shapes. Sub-package layout (mirrors univeros/cookie, univeros/session, etc.) - src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php - src/Altair/Idempotency/Storage/{StoredResponse,InMemoryStore, ApcuStore,RedisStore}.php - src/Altair/Idempotency/Exception/IdempotencyException.php - src/Altair/Idempotency/composer.json (require php >=8.3; ext-apcu / ext-redis declared as suggestions) - Wired into root composer.json's replace block. Contract - claim(key, requestHash, ttl): atomic; null = caller owns the key and must execute, StoredResponse = key already present (replay or in-progress, distinguished by `inProgress`). - complete(key, response, ttl): persists the captured response under a previously-claimed key. - release(key): drops the claim — used when the handler threw, so the next attempt starts fresh. - get(key): read-only inspection. Adapters - InMemoryStore: process-local; suitable for tests + single-worker scripts. Injectable clock for deterministic TTL tests. - ApcuStore: single-host production. apcu_add is the atomic claim; apcu_store overwrites on complete. Throws at construction time when ext-apcu is unavailable rather than silently degrading. - RedisStore: multi-host production. SET key value NX EX ttl is the atomic claim; constructor consumes a pre-configured \Redis client so connection lifecycle stays the host's responsibility. StoredResponse - Final readonly value object with toArray/fromArray + toJson/fromJson. - inProgress / completed named constructors keep call sites readable. - Malformed header rows on hydration are dropped rather than thrown to keep replay robust against partial stored writes. Tests (26 new) - StoredResponse: round-trip via array + JSON + missing-field / malformed-input rejection paths. - InMemoryStore: full coverage including expired-entry reaping via the injectable clock. - ApcuStore: skipped when ext-apcu (CLI-enabled) is absent; otherwise covers fresh-claim, concurrent-claim, complete-then-get, release, and per-prefix namespace isolation. - RedisStore: skipped when ext-redis is absent or no Redis reachable; uses db 15 with flushDB cleanup so it doesn't trample real data. Out of scope (per #171 epic) - The PSR-15 middleware that drives the store — #173. - The idempotency: spec block — #174. - The x-altair-idempotency round-trip activation — #175. - The package doc + benchmark variant — #176. Part of #171.
This was referenced May 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #172. Part of #171.
Summary
Adds the storage layer the rest of the Idempotency-Key epic builds on:
IdempotencyStoreInterface+StoredResponsevalue object + three adapters (InMemory / APCu / Redis). New sub-packageuniveros/idempotencymirrors the layout ofuniveros/cookie,univeros/session, etc.Contract
claim()is the atomic primitive —null= caller owns the key and must execute,StoredResponse with inProgress=true= another caller already claimed,StoredResponse with inProgress=false= cached response ready to replay.Adapters
InMemoryStoreApcuStoreapcu_add(insert-only)RedisStoreSET key value NX EX ttlApcuStorethrows at construction time whenext-apcuis unavailable;RedisStoreaccepts a pre-configured\Redisclient so connection lifecycle stays the host's responsibility. Both expose a configurablekeyPrefix(defaultaltair.idem.) so multiple apps sharing one backend don't collide.StoredResponse
Final readonly value object with
toArray/fromArray+toJson/fromJson. Named constructorsStoredResponse::inProgress()/StoredResponse::completed()keep call sites readable. Malformed header rows on hydration are dropped rather than thrown so replay stays robust against partial stored writes.Tests
26 new across
tests/Idempotency/Storage/:ext-apcu(CLI-enabled) is absent; otherwise covers fresh-claim, concurrent-claim, complete-then-get, release, and per-prefix namespace isolation.ext-redisis absent or no Redis is reachable; uses db 15 withflushDBcleanup so it doesn't trample real data.CI has
ext-redis; APCu CLI-enabled is typically not provisioned, so the APCu suite will skip there too \xe2\x80\x94 unit logic is identical to InMemoryStore and is exercised by it.Out of scope (per #171)
idempotency:spec block \xe2\x80\x94 idempotency: spec block + scaffolder integration (AST + parser + validator + emitter wiring) #174.x-altair-idempotencyround-trip activation \xe2\x80\x94 x-altair-idempotency round-trip activation: forward emit + reverse import + drift gate #175.Test plan
composer cs\xe2\x80\x94 greencomposer stan\xe2\x80\x94 greencomposer rector(full tree, no cache) \xe2\x80\x94 greencomposer test\xe2\x80\x94 6255 tests (+26 new), 22 skipped (12 pre-existing env + 10 new APCu/Redis gated), 5 pre-existing env errors unchangedbin/altair manifest:generate\xe2\x80\x94 clean;.agent/packages/idempotency.mdlands automatically