180 lines
8.9 KiB
Markdown
180 lines
8.9 KiB
Markdown
# OpenSpec Change: enterprise-idempotency
|
|
|
|
- **Change ID**: enterprise-idempotency
|
|
- **Package**: `@suaveplan/idempotency`
|
|
- **Category**: universal
|
|
- **Target path**: `packages/universal/idempotency`
|
|
- **Status**: PROPOSED
|
|
- **Date**: 2026-04-19
|
|
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/idempotency.md`
|
|
|
|
## Summary
|
|
|
|
Greenfield implementation of `@suaveplan/idempotency` for the genesis branch. The existing main-branch idempotency package (98.68% test coverage) provides the baseline public-API surface to preserve; this change delivers enterprise-grade idempotency key guarding and deduplication with a full state machine (`pending → completed | failed`), pluggable store adapters (memory, database, Redis), distributed locking via a `LockProvider` interface, HTTP middleware, key generation and fingerprinting, key versioning, metrics collection, injectable clock and RNG, `AbortSignal` support throughout, structured errors with stable `IDEMPOTENCY.*` codes, Zod schemas, OTel instrumentation, property-based tests, and benchmarks.
|
|
|
|
## Current-state audit (genesis)
|
|
|
|
The package does not exist on genesis yet — this is a fresh build.
|
|
|
|
## Available workspace deps on genesis
|
|
|
|
| Package | Available |
|
|
|---------|-----------|
|
|
| `@suaveplan/error` | ✅ |
|
|
| `@suaveplan/types` | ✅ |
|
|
| `@suaveplan/events` | ✅ |
|
|
| `@suaveplan/crypto-utils` | ✅ |
|
|
| `@suaveplan/crypto-hash` | ✅ |
|
|
| `@suaveplan/crypto-curve` | ✅ |
|
|
| `@suaveplan/crypto-cipher` | ✅ |
|
|
| `@suaveplan/crypto` | ✅ |
|
|
| `@suaveplan/crypto-keys` | ✅ |
|
|
| `@suaveplan/logger` | ✅ |
|
|
| `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly |
|
|
| `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly |
|
|
| `@suaveplan/streams` | ❌ not yet built — skip streaming features |
|
|
| `@suaveplan/feature-flags` | ❌ not yet built — skip |
|
|
| `@suaveplan/retry` | ❌ in progress — skip retry integration for now |
|
|
|
|
External deps (via catalog):
|
|
|
|
- `zod` → `catalog:forms`
|
|
- `@opentelemetry/api` → `catalog:observability`
|
|
- `tslib` → `catalog:build`
|
|
|
|
Dev deps (via catalog):
|
|
|
|
- `vitest`, `@vitest/coverage-istanbul` → `catalog:testing`
|
|
- `fast-check` → `catalog:testing`
|
|
- `mitata` → `catalog:testing`
|
|
|
|
## Preserved public API surface (from main branch)
|
|
|
|
### Distributed
|
|
|
|
- `DistributedIdempotencyGuard` class — distributed-safe guard backed by a `LockProvider`
|
|
- `LockAcquisitionError` extends `SuaveplanError`
|
|
- `MemoryLockProvider` — in-process lock provider for unit tests and single-node deployments
|
|
- `RedisLockProvider` — Redis-backed lock provider with lease/refresh semantics
|
|
- `RedisLockProviderConfig` — configuration type for `RedisLockProvider`
|
|
|
|
### Guard
|
|
|
|
- `IdempotencyConflictError` extends `SuaveplanError` — thrown when a pending record is detected for the same key
|
|
- `IdempotencyGuard` class — single-node guard with state machine and pluggable store
|
|
- `IdempotencyMismatchError` extends `SuaveplanError` — thrown when the request fingerprint differs from the stored record
|
|
|
|
### Key generation
|
|
|
|
- `compositeKey(...parts: string[]) → string` — assembles a namespaced key from ordered parts
|
|
- `fingerprintPayload(input: unknown, hashFn?) → string` — deterministic hex fingerprint of arbitrary input
|
|
- `generateIdempotencyKey(config?: KeyGeneratorConfig) → string` — generates a random, optionally prefixed key
|
|
|
|
### Error codes
|
|
|
|
- `IdempotencyErrorCode` — union type of all stable error code strings
|
|
- `IDEMPOTENCY_ERROR_METADATA` — `Record<IdempotencyErrorCode, CodeMetadata>` mapping
|
|
- `registerIdempotencyErrorCodes()` — registers all codes on the shared `ErrorCodes` singleton
|
|
|
|
### Metrics
|
|
|
|
- `IdempotencyMetrics` class — collects and exposes a rolling `IdempotencyMetricsSnapshot`
|
|
|
|
### Middleware
|
|
|
|
- `createIdempotencyMiddleware<TReq, TRes>(config)` → middleware handler function
|
|
- `MiddlewareResult<TRes>` — discriminated union returned by the middleware (`hit | miss | conflict`)
|
|
|
|
### Store adapters
|
|
|
|
- `IdempotencyStoreAdapter<T>` — interface: `get`, `set`, `delete`, `cleanup`
|
|
- `IdempotencyDatabaseAdapter` — interface for plugging in a raw database client
|
|
- `RedisClientAdapter` — interface for plugging in a raw Redis client
|
|
- `MemoryIdempotencyStore` — in-process TTL store, GC via `cleanup()`
|
|
- `DatabaseIdempotencyStore` — adapter-backed persistent store
|
|
- `DatabaseIdempotencyStoreConfig` — configuration type
|
|
- `RedisIdempotencyStore` — Redis-backed store with atomic set-if-not-exists
|
|
- `RedisIdempotencyStoreConfig` — configuration type
|
|
|
|
### Types
|
|
|
|
- `ConflictResolutionStrategy` — `'reject' | 'overwrite' | 'version'`
|
|
- `DistributedGuardConfig` — configuration for `DistributedIdempotencyGuard`
|
|
- `IdempotencyGuardConfig` — configuration for `IdempotencyGuard`
|
|
- `IdempotencyMetricsConfig` — configuration for `IdempotencyMetrics`
|
|
- `IdempotencyMetricsSnapshot` — point-in-time metrics snapshot shape
|
|
- `IdempotencyMiddlewareConfig<TReq, TRes>` — configuration for middleware
|
|
- `IdempotencyRecord<T>` — stored record shape with status, response, fingerprint, timestamps
|
|
- `IdempotencyStatus` — `'pending' | 'completed' | 'failed'`
|
|
- `IdempotencyStoreAdapter<T>` — store interface
|
|
- `KeyGeneratorConfig` — key prefix + separator options
|
|
- `KeyVersioningConfig` — store + strategy + maxVersions options
|
|
- `LockProvider` — interface: `acquire(resource, ttlMs)`, `release(resource)`
|
|
- `MetricsReporter` — callback type `(snapshot: IdempotencyMetricsSnapshot) => void`
|
|
- `VersionEntry<T>` — versioned record snapshot
|
|
|
|
### Versioning
|
|
|
|
- `KeyVersioning` class — multi-version record management with configurable conflict strategy
|
|
- `VersionConflictError` extends `SuaveplanError` — thrown when version limit is exceeded
|
|
|
|
## New additions for genesis
|
|
|
|
### Structured errors
|
|
|
|
- `IdempotencyError` base class extends `SuaveplanError` with codes under `IDEMPOTENCY.*` namespace
|
|
- Codes: `IDEMPOTENCY.KEY_CONFLICT`, `IDEMPOTENCY.RESPONSE_MISMATCH`, `IDEMPOTENCY.STORE_ERROR`, `IDEMPOTENCY.KEY_EXPIRED`, `IDEMPOTENCY.KEY_INVALID`, `IDEMPOTENCY.LOCK_ACQUISITION_FAILED`, `IDEMPOTENCY.VERSION_CONFLICT`
|
|
- `IdempotencyConflictError`, `IdempotencyMismatchError`, `LockAcquisitionError`, `VersionConflictError` all extend `IdempotencyError`
|
|
|
|
### Zod schemas
|
|
|
|
- `IdempotencyGuardConfigSchema`, `DistributedGuardConfigSchema`, `KeyGeneratorConfigSchema`, `KeyVersioningConfigSchema`, `IdempotencyMetricsConfigSchema`, `IdempotencyMiddlewareConfigSchema`
|
|
- `IdempotencyRecordSchema`, `IdempotencyStatusSchema`
|
|
|
|
### Injectable clock + RNG
|
|
|
|
- `IdempotencyGuardConfig.clock?: Clock` — for deterministic TTL and timeout computation (FakeClock in tests)
|
|
- `IdempotencyGuardConfig.rng?: Rng` — for deterministic key generation
|
|
- `DistributedGuardConfig.clock?: Clock`
|
|
- `KeyGeneratorConfig.rng?: Rng`
|
|
|
|
### AbortSignal support
|
|
|
|
- `IdempotencyGuard.execute(key, fn, opts?: { signal? })` — cancels pending-record wait on abort
|
|
- `DistributedIdempotencyGuard.execute(key, fn, opts?: { signal? })` — propagates signal through lock acquisition retries and fn invocation
|
|
|
|
### OTel instrumentation
|
|
|
|
- Counter: `idempotency.request.total` (by outcome label: `hit | miss | conflict | mismatch | error`)
|
|
- Histogram: `idempotency.execution.duration_ms` (guard execution latency)
|
|
- Counter: `idempotency.lock.acquisition.total` (by result: `acquired | failed | retried`)
|
|
- Histogram: `idempotency.lock.wait_ms` (time spent acquiring a distributed lock)
|
|
- Counter: `idempotency.store.operation.total` (by operation: `get | set | delete | cleanup`)
|
|
|
|
### Property-based tests (fast-check)
|
|
|
|
- `compositeKey` output is always deterministic for equal inputs
|
|
- `fingerprintPayload` is collision-resistant: distinct inputs produce distinct hashes across arbitrary payloads
|
|
- `generateIdempotencyKey` never produces duplicate outputs across N iterations with a mocked RNG
|
|
- `IdempotencyGuard.execute` never executes `fn` more than once for the same key when called concurrently
|
|
- `KeyVersioning` never exceeds `maxVersions` stored entries regardless of call order
|
|
|
|
### Benchmarks (mitata)
|
|
|
|
- `MemoryIdempotencyStore.get` hot path — 10 000 cached reads
|
|
- `IdempotencyGuard.execute` with cached hit — zero-execution overhead
|
|
- `compositeKey` throughput — 1 000 000 iterations
|
|
- `fingerprintPayload` throughput — 10 000 small payloads
|
|
|
|
## Notes on integration testing
|
|
|
|
- `RedisIdempotencyStore` and `RedisLockProvider` require a real Redis instance. Integration tests use a `redis://localhost:6379` connection; no mock Redis. Tests are placed in `tests/integration/` and are guarded by a `TEST_REDIS_URL` environment variable.
|
|
- `DistributedIdempotencyGuard` is unit-tested with `MemoryLockProvider` only. Integration tests covering the full distributed path with Redis are in `tests/integration/`.
|
|
- `DatabaseIdempotencyStore` integration tests require `TEST_DATABASE_URL` environment variable; unit tests use `MemoryIdempotencyStore` as a stand-in.
|
|
|
|
## Impact
|
|
|
|
- Adds one new package: `packages/universal/idempotency`
|
|
- Adds one new spec capability: `idempotency`
|
|
- No existing package consumers on genesis yet
|