shamanzpua/idempotency-engine
Composer 安装命令:
composer require shamanzpua/idempotency-engine
包简介
Framework-agnostic PHP idempotency engine for safe retries, concurrency control and duplicate suppression in distributed systems
README 文档
README
Framework-agnostic PHP library for single-flight execution, duplicate suppression and result
replay per idempotency key: at most one active execution per (scope, key), with retries
returning the stored result. Built for payment operations, webhook handlers, API idempotency
keys and at-least-once queue consumers.
Not a strict exactly-once guarantee — see Guarantees and limitations. For critical side effects, combine it with a provider-side idempotency key, a unique DB constraint and/or a transactional outbox.
- Atomic claim — one writer wins per
(scope, key); no check-then-act races (single upsert + row lock in SQL, single Lua script in Redis). - Ownership tokens — only the execution that claimed a key can complete or fail it;
a zombie worker gets
OwnershipViolationExceptioninstead of silently overwriting state. - Fingerprint protection — a retry with the same key but a different payload is rejected
with
FingerprintMismatchException(Stripe-style). - Result replay — repeated calls return the stored result without re-executing.
- Conflict policies — concurrent execution: throw or wait (polling with backoff + jitter); previous failure: throw or retry with a budget.
- Zero runtime dependencies. PHP 8.2+, MIT.
Storage backend support
| Backend | Status |
|---|---|
| MySQL >= 8.0 | Supported |
| MariaDB >= 10.5 | Supported |
| PostgreSQL >= 13 | Supported |
| Redis >= 6.0 | Supported |
| SQLite | Not supported (locking requires SELECT ... FOR UPDATE) |
Installation
composer require shamanzpua/idempotency-engine
For SQL backends, apply the schema once:
- MySQL/MariaDB:
resources/sql/idempotency_records.mysql.sql - PostgreSQL:
resources/sql/idempotency_records.postgresql.sql
For Redis, install either ext-redis (use PhpRedisClient) or predis/predis (use PredisClient).
Quickstart
use Shamanzpua\Idempotency\Core\Engine\DefaultIdempotencyEngine; use Shamanzpua\Idempotency\Core\Model\ExecutionOptions; use Shamanzpua\Idempotency\Core\Policy\DefaultExecutionPolicy; use Shamanzpua\Idempotency\Core\Service\ExecutionRunner; use Shamanzpua\Idempotency\Infrastructure\Fingerprint\CanonicalJsonFingerprintGenerator; use Shamanzpua\Idempotency\Infrastructure\Serialization\JsonResultSerializer; use Shamanzpua\Idempotency\Infrastructure\Store\Pdo\PdoIdempotencyStore; use Shamanzpua\Idempotency\Support\Clock\SystemClock; $pdo = new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $engine = new DefaultIdempotencyEngine( store: new PdoIdempotencyStore($pdo), fingerprintGenerator: new CanonicalJsonFingerprintGenerator(), serializer: new JsonResultSerializer(), policy: new DefaultExecutionPolicy(), runner: new ExecutionRunner(), clock: new SystemClock(), ); $result = $engine->execute( key: $idempotencyKey, // e.g. the Idempotency-Key request header operation: fn () => $payments->charge($order), options: new ExecutionOptions( scope: 'payments', payload: $requestBody, // fingerprint source for mismatch detection ), );
The first call executes the operation and stores the result. Any subsequent call with the
same (scope, key) and the same payload returns the stored result without executing again.
Execution options
use Shamanzpua\Idempotency\Core\ValueObject\Ttl; use Shamanzpua\Idempotency\Enum\FailedStrategy; use Shamanzpua\Idempotency\Enum\InProgressStrategy; new ExecutionOptions( scope: 'webhooks', // namespace for keys (default: "default") payload: $payload, // fingerprint source ttl: Ttl::fromSeconds(300), // claim TTL; choose >= worst-case operation duration resultTtl: Ttl::fromSeconds(86_400), // optional: how long the result stays replayable inProgressStrategy: InProgressStrategy::WAIT, // or THROW (default) failedStrategy: FailedStrategy::RETRY, // or THROW (default) maxRetries: 2, waitTimeoutMs: 2_000, );
- When
resultTtlis omitted, the record keeps the expiry set at claim time (Stripe/AWS-style fixed window). When provided,complete()/fail()extend the expiry from completion time. InProgressStrategy::WAITpolls with exponential backoff and jitter until the concurrent execution finishes, then replays its result.
Exceptions you should handle
| Exception | Meaning |
|---|---|
FingerprintMismatchException |
Same key, different payload — client bug or key reuse. |
OperationInProgressException |
Another execution is active (THROW strategy, or WAIT timed out). |
OperationFailedException |
A prior attempt failed and failedStrategy is THROW, or a caller observed an already-failed record under RETRY with the budget exhausted. (When a caller exhausts its own retries, the operation's original exception is re-thrown instead.) |
StoreException |
Backend I/O failure. |
Record lifecycle
claim() creates a record in IN_PROGRESS. The owner transitions it to COMPLETED (with the
serialized result) or FAILED (with error details). A FAILED or expired record can be
reclaimed by a new execution. Completed results are replayed until the record expires.
Store notes
PDO (MySQL / MariaDB / PostgreSQL)
- The claim does not rely on the driver's affected-rows reporting on MySQL, so connections
with
PDO::MYSQL_ATTR_FOUND_ROWSare safe (at the cost of one extra indexedSELECTper claim). - The connection must already use
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION; the store no longer mutates your (possibly shared) connection and throwsInvalidArgumentExceptionif it is configured otherwise. claim()opens and commits its own transaction, so it must not run inside an ambient transaction you started (PDO has no true nested transactions). Give the store a dedicated connection, or call it outside your ownbeginTransaction()/commit()block.- Timestamps are persisted as canonical UTC wall-clock values regardless of the process
timezone, so
expires_atcomparisons stay correct across workers in different zones. scopeandidempotency_keyare case-sensitive, byte-exact and NOT PAD — trailing spaces are significant (MySQL usesVARBINARY). Keep them under 191 bytes.- Expired rows are not removed automatically; schedule
resources/sql/cleanup_expired.mysql.sql(or an equivalent) via cron, or calldeleteExpired()(ExpirableStore) from your scheduler.
Redis
- All mutations are single atomic Lua scripts; key expiry uses native Redis TTL
(
deleteExpired()is a no-op). - The claim sets the physical key TTL to the claim
ttl, so a record is evicted exactly at its logical expiry (unlike PDO, whose rows linger until garbage-collected). An operation that outruns its claimttltherefore loses its record before it cancomplete(). Set claimttlabove the worst-case operation duration (see Guarantees and limitations). - Timestamps are normalized to UTC before they reach the Lua expiry comparison, so a fleet spread across timezones (or crossing a DST boundary) never mis-orders a live lease as expired.
- Pass a "plain" client: phpredis with
OPT_SERIALIZERorOPT_PREFIXconfigured is not supported — Lua scripts write raw JSON which a serializer would corrupt and a prefix would split into different keys. Use a dedicated connection if your app configures those options. - Replay durability depends on Redis persistence settings (
appendonly/save); a restart without persistence forgets in-flight and completed records. - Redis Cluster: scripts are single-key, so any setup works. Keys use a length-prefixed
prefix:<len>:scope:<len>:keyencoding so distinct(scope, key)pairs never collide.
InMemory
InMemoryIdempotencyStore is for tests and single-process scenarios only; it is not
thread-safe for async/shared-memory runtimes.
Guarantees and limitations
What the engine guarantees (per supported store):
- At most one execution holds a
(scope, key)claim at any moment — verified by cross-process concurrency stress tests for all three backends. - Only the claim owner can publish a result or a failure.
- A replayed result is byte-identical to the stored serialization of the first result.
- A logically expired record is never replayed:
get()reports it as absent across every store, and a fresh call re-executes. - A previously failed operation is not silently re-run: with
FailedStrategy::THROWa fresh call raisesOperationFailedException; withRETRYit is atomically reclaimed.
What it does not guarantee:
- Strict exactly-once. If the operation succeeds but persisting the result fails, the
record is marked
FAILEDand a later retry will re-execute the operation. Design operations to be safe under rare re-execution, or keep side effects transactional with your own storage. - Rollback of side effects. A failed operation is recorded, not undone — this is not a saga framework.
- Object round-trips through replay.
JsonResultSerializerreturns associative arrays for any objects in the result. Return JSON-friendly data from operations, or plug in your ownResultSerializer. - Fingerprint canonicalization is opt-in.
CanonicalJsonFingerprintGenerator(recommended) makes fingerprints order-independent and version-prefixed (v1:…); the olderSha256FingerprintGeneratorhashes rawjson_encode($payload), so array key order matters. Withoutpayloadorfingerprint, the fingerprint falls back to the key alone and payload-mismatch protection is disabled. - Non-blocking waits.
InProgressStrategy::WAITpolls withusleep()and blocks the current process; avoid long waits inside synchronous HTTP workers. - Heartbeat staleness detection. There is no mid-flight liveness signal: a crashed holder
keeps the claim until its lease (claim
ttl) expires. Once expired, the record is treated as absent and the next claim (or aWAITpoll) automatically takes it over and re-executes; there is no separate "stalled" exception. Sizettlaccordingly (see below). - Claim TTL must exceed the worst-case operation duration. The claim
ttlis the lease, and completing after it has expired is outside the supported contract: another worker may have already taken the key over, so the outcome of a latecomplete()/fail()is store-specific and must not be relied on. On PDO/InMemory a late completion by the last owner may still succeed until the row is garbage-collected; on Redis the key is evicted at the lease boundary (native TTL), so a latecomplete()finds nothing and the successful result is lost with aRuntimeException. Always setttlcomfortably above the slowest expected run. Heartbeat / lease-renewal (and a uniform expired-lease rejection) are not provided in 1.0.
Testing
composer test:unit # unit suite, no services required composer test # unit + integration (needs MySQL, PostgreSQL, Redis; see env vars in tests)
Integration suites read DB_DSN/DB_USER/DB_PASSWORD (MySQL), PG_DB_DSN/PG_DB_USER/
PG_DB_PASSWORD (PostgreSQL) and REDIS_DSN. Cross-process claim contention tests live in
tests/Integration/Concurrency/, and one behavioural contract is run against every store in
tests/Integration/Contract/ to keep all supported lifecycle scenarios observably identical
across backends. CI runs the full MySQL/PostgreSQL/Redis integration suite on PHP 8.2–8.5,
including both the Predis and phpredis adapters, plus a MariaDB compatibility job, on every pull
request, on pushes to main and release branches, and on version tags.
License
MIT. See LICENSE.
shamanzpua/idempotency-engine 适用场景与选型建议
shamanzpua/idempotency-engine 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 4 次下载、GitHub Stars 达 1, 最近一次更新时间为 2026 年 06 月 10 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「php」 「api」 「backend」 「concurrency」 「idempotency」 「retries」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 shamanzpua/idempotency-engine 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 shamanzpua/idempotency-engine 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 shamanzpua/idempotency-engine 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
This bundle offers a simple backend explanation form field (inputType).
Preview of settings in footer of tt_content element in page module.
A PSR-7 compatible library for making CRUD API endpoints
An interactive tour through the TYPO3 backend.
crud widgets for laravel, to make an admin in few lines of code
Define your own css rule-sets for the TYPO3 CMS backend.
统计信息
- 总下载量: 4
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 1
- 点击次数: 36
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-10