Skip to content

feat(idempotency): univeros/idempotency — storage contract + adapters (#172)#177

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/172-idempotency-storage
May 31, 2026
Merged

feat(idempotency): univeros/idempotency — storage contract + adapters (#172)#177
tonydspaniard merged 1 commit into
masterfrom
feat/172-idempotency-storage

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

Closes #172. Part of #171.

Summary

Adds the storage layer the rest of the Idempotency-Key epic builds on: IdempotencyStoreInterface + StoredResponse value object + three adapters (InMemory / APCu / Redis). New sub-package univeros/idempotency mirrors the layout of univeros/cookie, univeros/session, etc.

Contract

interface IdempotencyStoreInterface
{
    public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse;
    public function complete(string $key, StoredResponse $response, int $ttlSeconds): void;
    public function release(string $key): void;
    public function get(string $key): ?StoredResponse;
}

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

Adapter Atomic claim primitive Use case
InMemoryStore Process-local array + injectable clock Tests, single-worker scripts
ApcuStore apcu_add (insert-only) Single-host production
RedisStore SET key value NX EX ttl Multi-host production

ApcuStore throws at construction time when ext-apcu is unavailable; RedisStore accepts a pre-configured \Redis client so connection lifecycle stays the host's responsibility. Both expose a configurable keyPrefix (default altair.idem.) so multiple apps sharing one backend don't collide.

StoredResponse

Final readonly value object with toArray/fromArray + toJson/fromJson. Named constructors StoredResponse::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/:

  • StoredResponseTest — round-trip via array + JSON, missing-field rejection, malformed-input rejection, header-row sanitisation.
  • InMemoryStoreTest — claim / complete / release / get + lazy expiry via the injectable clock.
  • ApcuStoreTest — skipped when ext-apcu (CLI-enabled) is absent; otherwise covers fresh-claim, concurrent-claim, complete-then-get, release, and per-prefix namespace isolation.
  • RedisStoreTest — skipped when ext-redis is absent or no Redis is reachable; uses db 15 with flushDB cleanup 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)

Test plan

  • composer cs \xe2\x80\x94 green
  • composer stan \xe2\x80\x94 green
  • composer rector (full tree, no cache) \xe2\x80\x94 green
  • composer test \xe2\x80\x94 6255 tests (+26 new), 22 skipped (12 pre-existing env + 10 new APCu/Redis gated), 5 pre-existing env errors unchanged
  • bin/altair manifest:generate \xe2\x80\x94 clean; .agent/packages/idempotency.md lands automatically

…#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.
@tonydspaniard tonydspaniard merged commit bbc50bf into master May 31, 2026
4 checks passed
@tonydspaniard tonydspaniard deleted the feat/172-idempotency-storage branch May 31, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

univeros/idempotency: storage contract + adapters (InMemory, APCu, Redis)

1 participant