This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 9951a4d649 [#12049] design-docs: design document for Idempotency-Key
support in Iceberg REST server (#12050)
9951a4d649 is described below
commit 9951a4d6492c100bbf11cb961dd09a00e7fb0868
Author: Akshay Thorat <[email protected]>
AuthorDate: Wed Aug 12 05:36:07 2026 -0700
[#12049] design-docs: design document for Idempotency-Key support in
Iceberg REST server (#12050)
### What changes were proposed in this pull request?
Add a design document (`design-docs/iceberg-idempotency-key.md`) for
Idempotency-Key support in the Iceberg REST server, covering multi-node
consistency and database-backed storage.
The document covers:
- **Problem statement**: In-memory caches are not safe for multi-node
deployments or node failures
- **IdempotencyStore SPI**: Pluggable storage interface with
`reserve/finalize/release/load/purgeExpired` operations
- **Two implementations**: `InMemoryIdempotencyStore` (dev) and
`JdbcIdempotencyStore` (production)
- **Database schema**: `idempotency_records` table with row lifecycle
(RESERVED -> FINALIZED -> EXPIRED)
- **Request flow**: Happy path, replay path, and failure handling
- **Comparison with Polaris**: Simplified model (no
heartbeat/reconciliation) since Iceberg mutations are sub-second
- **Implementation phases**: SPI + in-memory -> JDBC store ->
observability
Reference:
[apache/polaris#3205](https://github.com/apache/polaris/pull/3205)
### Why are the changes needed?
Epic #12049. Reviewers on PR #10751 requested a design document covering
multi-node idempotency before proceeding with implementation.
### Does this PR introduce any user-facing change?
No. Documentation only.
### How was this patch tested?
N/A - design document only.
---
design-docs/iceberg-idempotency-key.md | 501 +++++++++++++++++++++++++++++++++
1 file changed, 501 insertions(+)
diff --git a/design-docs/iceberg-idempotency-key.md
b/design-docs/iceberg-idempotency-key.md
new file mode 100644
index 0000000000..4e6723bfe9
--- /dev/null
+++ b/design-docs/iceberg-idempotency-key.md
@@ -0,0 +1,501 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Design: Idempotency-Key Support for the Gravitino Iceberg REST Server
+
+| Field | Value |
+| ------- | ------------------------------------------------------- |
+| Status | Draft |
+| Authors | @laserninja |
+| Created | 2026-07-16 |
+| Module | `iceberg/iceberg-rest-server`, `iceberg/iceberg-common` |
+
+---
+
+## 1. Background
+
+The Iceberg REST spec defines an optional `Idempotency-Key` header (UUIDv7) on
+all mutation endpoints (create/update/delete for tables, namespaces, and
views).
+When present, the server must:
+
+1. Execute the mutation on first receipt and store the response.
+2. Return the stored response on subsequent retries with the same key.
+3. Advertise the key reuse window via `idempotency-key-lifetime` in
+ `GET /v1/config`.
+
+Without this, network failures and client retries can cause duplicate
+table/namespace/view creation or deletion.
+
+### 1.1 Multi-Node Problem
+
+Gravitino's Iceberg REST server can run as multiple replicas behind a load
+balancer. A purely in-memory cache (e.g., Caffeine) is node-local:
+
+- **Retry hits different node**: The second node has no record of the first
+ execution and re-runs the mutation (duplicate side-effect).
+- **Node crash**: All cached responses are lost; retries execute again.
+
+A production-grade solution requires shared, durable storage for idempotency
+records.
+
+### 1.2 Prior Art
+
+Apache Polaris introduced database-backed idempotency support in
+[apache/polaris#3205](https://github.com/apache/polaris/pull/3205) with a
+Postgres-backed `IdempotencyStore` SPI. Their design includes heartbeat-based
+liveness, executor ownership, crash reconciliation, and minimal response
+reconstruction. Gravitino can adopt a simplified version of this pattern that
+fits its existing relational backend and shorter mutation lifecycle.
+
+Note that in Polaris idempotency is **disabled by default** and must be
+explicitly enabled. This design follows the same convention (see
+[Section 6](#6-configuration)).
+
+### 1.3 Idempotency-Key Format: UUIDv7 (RFC 9562)
+
+The Iceberg REST spec requires the `Idempotency-Key` header to be a **UUIDv7 in
+string form**, as defined by [RFC
9562](https://www.rfc-editor.org/rfc/rfc9562).
+RFC 9562 obsoletes RFC 4122 and standardizes the newer time-ordered UUID
+versions (6, 7, and 8). The requirement is stated directly in the spec's
+`Idempotency-Key` parameter definition: *"Key format: UUIDv7 in string form
+(RFC 9562)"* (see
+[`rest-catalog-open-api.yaml`](https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml)).
+
+UUIDv7 encodes a 48-bit Unix timestamp (milliseconds) in its most-significant
+bits, followed by version/variant bits and random data. This gives it two
+properties this design benefits from:
+
+- **Time-ordered / monotonic**: Keys generated later sort after keys generated
+ earlier. Used as a primary key, inserts are append-mostly, which reduces
+ B-tree page splits and index fragmentation on MySQL/PostgreSQL compared to
+ fully-random UUIDv4.
+- **Embedded creation time**: The timestamp prefix lets the server derive an
+ approximate creation instant from the key itself, useful for diagnostics and
+ as a sanity check, and aligns naturally with TTL-based expiry.
+
+**Why this design follows RFC 9562:**
+
+1. **Spec compliance**: The Iceberg REST spec mandates `UUIDv7 in string form
+ (RFC 9562)`. Validating against RFC 9562 (version nibble `7`, correct
+ variant bits, 36-char canonical form) lets the server reject malformed keys
+ with `400 Bad Request` before touching storage, and guarantees
+ interoperability with any spec-compliant client.
+2. **Storage efficiency**: The time-ordered property keeps the
+ `idempotency_key` primary-key index compact and insert-friendly, which
+ matters most for the `jdbc` store under high write throughput.
+3. **No custom format**: Following the standard means the server does not
+ impose a non-standard key format that clients would have to special-case.
+
+**Validation rules applied by the server:**
+
+- Length must be exactly 36 characters (canonical hyphenated form), matching
+ the spec's `minLength`/`maxLength` of 36.
+- Must parse as a UUID whose version field reports `7`.
+- Must use the RFC 9562 variant (the two most-significant bits of the variant
+ octet are `10`).
+
+Correctness does **not** depend on the embedded timestamp: expiry uses a
+server-assigned `expires_at` (see [Section 4](#4-database-schema)). The
+timestamp is used only as a defense-in-depth sanity check and for diagnostics.
+
+---
+
+## 2. Goals
+
+1. Prevent duplicate side-effects when clients retry mutations with the same
key.
+2. Work correctly in multi-node deployments (shared storage).
+3. Survive node failures (durable state).
+4. Minimal latency overhead on the happy path (single extra DB round-trip).
+5. Configurable storage backend (pluggable SPI).
+6. Spec-compliant: validate UUIDv7 format, advertise lifetime in config.
+
+### Non-Goals
+
+- Cross-datacenter replication of idempotency records.
+- Idempotency for read (GET/HEAD) operations.
+- Guaranteed delivery (client-side concern).
+- Heartbeat/liveness tracking (mutations are sub-second).
+
+---
+
+## 3. Architecture
+
+### 3.1 Request Flow Overview
+
+```
+Client Load Balancer Node A / Node B
+ | | |
+ |-- POST + Idempotency-Key: <uuid> --------------->|
+ | | |
+ | | +--------------+---------------+
+ | | | 1. Validate UUIDv7 format |
+ | | | 2. store.reserve(key) |
+ | | | +- RESERVED -> execute |
+ | | | +- DUPLICATE -> replay |
+ | | | 3. Execute mutation |
+ | | | 4. store.finalize(key, resp) |
+ | | +--------------+---------------+
+ |<-- Response ------------------------------------ |
+```
+
+### 3.2 IdempotencyStore SPI
+
+A storage-agnostic interface that any backend can implement:
+
+```java
+public interface IdempotencyStore extends Closeable {
+
+ enum ReserveResult { RESERVED, DUPLICATE }
+
+ /**
+ * Attempt to reserve a key. Returns DUPLICATE if already reserved or
finalized.
+ *
+ * @param idempotencyKey the client-provided UUIDv7 key
+ * @param operationBinding request identity, e.g. "POST
/v1/cat1/namespaces/ns1/tables"
+ * @param expiresAt instant after which this record may be purged
+ * @return RESERVED if newly claimed, DUPLICATE if already exists
+ */
+ ReserveResult reserve(String idempotencyKey, String operationBinding,
Instant expiresAt);
+
+ /**
+ * Load a finalized record for replay.
+ *
+ * @param idempotencyKey the client-provided key
+ * @return the record if finalized, empty if not yet finalized or not found
+ */
+ Optional<IdempotencyRecord> load(String idempotencyKey);
+
+ /**
+ * Mark a reserved key as finalized with the response to replay on
duplicates.
+ *
+ * @param idempotencyKey the client-provided key
+ * @param httpStatus the HTTP status code of the original response
+ * @param responseSummary serialized response body (JSON string)
+ */
+ void finalize(String idempotencyKey, int httpStatus, String responseSummary);
+
+ /**
+ * Release a reservation so the client can retry with the same key.
+ * Called when the mutation fails with a retryable error.
+ *
+ * @param idempotencyKey the client-provided key
+ */
+ void release(String idempotencyKey);
+
+ /**
+ * Purge expired records. Called periodically by a background task.
+ *
+ * @param before cutoff instant; records expiring before this time are
removed
+ * @return number of records purged
+ */
+ int purgeExpired(Instant before);
+}
+```
+
+### 3.3 IdempotencyRecord
+
+```java
+public class IdempotencyRecord {
+ private final String idempotencyKey; // UUIDv7 (RFC 9562), 36-char
canonical form
+ private final String operationBinding; // e.g., "POST
/v1/cat1/namespaces/ns1/tables"
+ private final Integer httpStatus; // null while reserved / in-progress
+ private final String responseSummary; // serialized response body (JSON)
+ private final long createdAtMs; // unix epoch millis (matches
BIGINT column)
+ private final long expiresAtMs; // unix epoch millis (matches
BIGINT column)
+}
+```
+
+Timestamps are stored as `long` unix epoch milliseconds to match Gravitino's
+existing metadata tables (e.g., the `deleted_at` / `timestamp` columns in
+`metalake_meta`, `catalog_meta`, and `commit_metrics_report`), which all use
+`BIGINT` epoch millis rather than SQL `TIMESTAMP`.
+
+### 3.4 Storage Implementations
+
+| Implementation | Use Case | Durability |
Multi-Node |
+| ---------------------------- | --------------------- | ---------- |
---------- |
+| `InMemoryIdempotencyStore` | Dev, testing, single | No | No
|
+| `JdbcIdempotencyStore` | Production | Yes | Yes
|
+
+The JDBC implementation leverages Gravitino's existing relational backend
+(H2 for dev, MySQL or PostgreSQL for production).
+
+### 3.5 In-Memory Store Capacity and Eviction
+
+The `InMemoryIdempotencyStore` is a node-local Caffeine cache bounded by two
+limits:
+
+- **Time-based**: `Caffeine.expireAfterWrite(lifetime)` removes records once
+ their reuse window has elapsed (same TTL as `expires_at`).
+- **Size-based**: `Caffeine.maximumSize(idempotency-max-entries)` caps the
+ number of retained records.
+
+When the number of stored keys exceeds `idempotency-max-entries`, Caffeine
+evicts entries using its Window-TinyLFU policy (approximate LRU) **before**
they
+naturally expire. The consequences are:
+
+- An evicted-but-not-yet-expired key loses its stored response. A subsequent
+ retry with that key is then treated as a new request and the mutation is
+ re-executed. This weakens the idempotency guarantee under memory pressure.
+- Because eviction can also drop an in-progress `RESERVED` entry, the in-memory
+ store is explicitly documented as **best-effort, single-node, dev/test
only**.
+ Production deployments MUST use the `jdbc` store, which has no such cap.
+
+Mitigations:
+
+- Size `idempotency-max-entries` for the expected peak throughput multiplied by
+ the key lifetime (roughly `requests_per_second * lifetime_seconds`).
+- Emit an eviction-rate metric and log a warning when eviction is frequent,
+ signaling the cache is under-provisioned or that the `jdbc` store should be
+ used instead.
+
+---
+
+## 4. Database Schema
+
+The table name, column names, and column types follow Gravitino's existing
+metadata table conventions (see `scripts/mysql/schema-*.sql` and
+`scripts/mysql/iceberg-metrics-schema-*.sql`): a `*_meta` table name,
+backtick-quoted `snake_case` columns, `BIGINT` unix-epoch-millis timestamps, a
+MEDIUMTEXT `audit_info` column, per-column `COMMENT`s, and
+`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
+
+MySQL:
+
+```sql
+CREATE TABLE IF NOT EXISTS `iceberg_idempotency_key_meta` (
+ `idempotency_key` VARCHAR(36) NOT NULL COMMENT 'client-provided
UUIDv7 idempotency key',
+ `operation_binding` VARCHAR(512) NOT NULL COMMENT 'request
identity, e.g. POST /v1/{prefix}/namespaces/{ns}/tables',
+ `http_status` INT UNSIGNED DEFAULT NULL COMMENT 'HTTP status
of finalized response; NULL while reserved',
+ `response_summary` MEDIUMTEXT DEFAULT NULL COMMENT 'serialized
response body (JSON) for replay',
+ `audit_info` MEDIUMTEXT NOT NULL COMMENT 'idempotency
record audit info',
+ `created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'record created
at (unix epoch millis)',
+ `expires_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'record expires
at (unix epoch millis)',
+ PRIMARY KEY (`idempotency_key`),
+ KEY `idx_iik_expires` (`expires_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'Iceberg
REST idempotency key records';
+```
+
+The H2 and PostgreSQL variants follow the same column names and types (with
+database-specific keywords) and are shipped alongside the existing scripts as
+`scripts/h2/iceberg-idempotency-schema-<version>-h2.sql` and
+`scripts/postgresql/iceberg-idempotency-schema-<version>-postgresql.sql`,
+mirroring the layout of the existing `iceberg-metrics-schema-<version>-*.sql`
+files.
+
+### 4.1 Row Lifecycle
+
+1. **RESERVED**: Row inserted with `http_status = NULL`. Mutation is executing.
+2. **FINALIZED**: `http_status` and `response_summary` written. Ready for
replay.
+3. **EXPIRED**: `expires_at < now()`. Purged by background cleanup
+ (see [Section 5.4](#54-expiry-and-cleanup-of-outdated-data)).
+
+### 4.2 Conflict Handling
+
+`reserve()` uses `INSERT ... ON CONFLICT DO NOTHING` (PostgreSQL),
+`INSERT IGNORE` (MySQL), or equivalent. If zero rows inserted, the key is
+already reserved or finalized - return `DUPLICATE` and load the existing record
+for replay.
+
+---
+
+## 5. Detailed Request Flow
+
+### 5.1 Happy Path (First Request)
+
+1. If idempotency is disabled (`idempotency-enabled=false`, the default),
+ ignore the `Idempotency-Key` header and execute the mutation directly.
+2. Extract `Idempotency-Key` header; validate UUIDv7 format (400 if invalid).
+3. If header absent, execute mutation directly (no idempotency overhead).
+4. Call `store.reserve(key, binding, now + lifetime)`.
+5. Result = `RESERVED`: execute mutation, call `store.finalize(key, status,
body)`.
+6. Return response to client.
+
+### 5.2 Replay Path (Duplicate Key)
+
+1. Extract and validate header.
+2. Call `store.reserve(key, binding, expiresAt)` - returns `DUPLICATE`.
+3. Call `store.load(key)`:
+ - If finalized: reconstruct and return cached response.
+ - If in-progress (not yet finalized): return `409 Conflict` with
+ `Retry-After` header (another node is still executing).
+4. Return cached response.
+
+### 5.3 Execution Failure
+
+If the mutation throws an exception:
+
+- **Retryable errors** (e.g., transient DB failure): call `store.release(key)`
+ so the client can retry with the same key.
+- **Terminal errors** (e.g., `AlreadyExistsException`): call
+ `store.finalize(key, 409, errorBody)` so retries get the same error response.
+
+This matches the Iceberg REST spec's finalization rules for the
+`Idempotency-Key` header:
+
+- **Finalize & replay**: `200`, `201`, `204`, and deterministic terminal `4xx`
+ responses (including `409` such as `AlreadyExists`, `NamespaceNotEmpty`).
+- **Do not finalize** (not stored/replayed): `5xx` responses, which are treated
+ as retryable and cause `release()`.
+
+### 5.4 Expiry and Cleanup of Outdated Data
+
+Every record carries `expires_at = created_at + idempotency-key-lifetime`. The
+spec states catalogs SHOULD NOT expire keys before the advertised lifetime, so
+cleanup only ever removes records whose window has already elapsed.
+
+**JDBC store.** A background task scheduled every
+`idempotency-cleanup-interval` calls `store.purgeExpired(now)`, which runs:
+
+```sql
+DELETE FROM iceberg_idempotency_key_meta WHERE expires_at < ? LIMIT ?;
+```
+
+- Deletes are performed in bounded batches (using `LIMIT` / repeated execution)
+ to avoid long-held locks and large transactions.
+- The `idx_iik_expires` index makes the predicate efficient.
+- The delete is safe to run concurrently on multiple nodes: it is idempotent
+ and only targets already-expired rows, so no leader election or distributed
+ lock is required. A small grace period is added to the cutoff to tolerate
+ clock skew between nodes.
+
+**In-memory store.** No separate task is needed: Caffeine's
+`expireAfterWrite(lifetime)` removes expired entries lazily/automatically, and
+size-based eviction bounds memory
+(see [Section 3.5](#35-in-memory-store-capacity-and-eviction)).
+
+---
+
+## 6. Configuration
+
+All keys use the `gravitino.iceberg-rest.` prefix used by the Iceberg REST
+server. Only `idempotency-key-lifetime` is defined by the Iceberg REST spec and
+advertised to clients; every other key is Gravitino server-side configuration
+and is **not** exposed via `GET /v1/config`.
+
+| Config Key | Default | Description
|
+| ------------------------------------------------ | ----------- |
-------------------------------------------------------------------------------------------------------
|
+| `gravitino.iceberg-rest.idempotency-enabled` | `false` | Master
switch. Disabled by default (matching Polaris). When `false`, the
`Idempotency-Key` header is ignored and no lifetime is advertised. |
+| `gravitino.iceberg-rest.idempotency-key-lifetime`| `PT30M` | Client
reuse window (ISO-8601 duration). Advertised to clients as the spec-defined
`idempotency-key-lifetime` in `GET /v1/config`. |
+| `gravitino.iceberg-rest.idempotency-store-type` | `in-memory` | `in-memory`
or `jdbc`.
|
+| `gravitino.iceberg-rest.idempotency-max-entries` | `10000` | Max cached
entries (in-memory store only). See [Section
3.5](#35-in-memory-store-capacity-and-eviction).|
+| `gravitino.iceberg-rest.idempotency-cleanup-interval` | `PT5M` | Interval
for the background task that purges expired records (jdbc store).
|
+
+### 6.1 Iceberg Spec Configuration
+
+The only idempotency setting defined by the Iceberg REST spec is
+`idempotency-key-lifetime`, together with the `Idempotency-Key` request header.
+Both are defined in the Iceberg REST OpenAPI spec:
+
+- `Idempotency-Key` parameter and `idempotency-key-lifetime` `CatalogConfig`
+ field:
+
[`open-api/rest-catalog-open-api.yaml`](https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml)
+
+When idempotency is enabled, it is advertised in `GET /v1/config` as:
+
+```json
+{
+ "defaults": {
+ "idempotency-key-lifetime": "PT30M"
+ }
+}
+```
+
+Per the spec, presence of this field signals that the server supports
+`Idempotency-Key` semantics; when absent (i.e., `idempotency-enabled=false`),
+clients MUST assume idempotency is not supported.
+
+### 6.2 Database Configuration (`jdbc` store)
+
+When `idempotency-store-type=jdbc`, the store connects to a relational database
+using the following keys, mirroring the JDBC configuration style already used
by
+Gravitino's Iceberg REST metrics store:
+
+| Config Key | Default |
Description |
+| ------------------------------------------------------- | ------- |
------------------------------------------------------------------ |
+| `gravitino.iceberg-rest.idempotency-jdbc-url` | (none) | JDBC
connection URL, e.g. `jdbc:mysql://host:3306/gravitino`. |
+| `gravitino.iceberg-rest.idempotency-jdbc-driver` | (none) | JDBC
driver class name. |
+| `gravitino.iceberg-rest.idempotency-jdbc-user` | (none) | Database
user. |
+| `gravitino.iceberg-rest.idempotency-jdbc-password` | (none) | Database
password. Supplied via secret/env; never logged. |
+| `gravitino.iceberg-rest.idempotency-jdbc-max-pool-size` | `10` | Maximum
JDBC connection pool size. |
+
+By default the `jdbc` store reuses the same backend as Gravitino's relational
+entity store; the keys above allow pointing it at a dedicated database when
+needed.
+
+---
+
+## 7. Comparison with Polaris
+
+| Aspect | Polaris (PR #3205) | This Design
|
+| ------------------- | ------------------------------- |
----------------------------------------------- |
+| Storage | Postgres-specific | Gravitino relational
backend (H2/MySQL/PG) |
+| Heartbeat | Yes (long-running ops) | No (mutations are
sub-second) |
+| Reconciliation | Yes (crash recovery) | Simplified:
TTL-based expiry + release on fail |
+| Executor ownership | Yes (multi-worker) | No (single-owner per
key via DB constraint) |
+| Response storage | Minimal summary + pointer | Full response body
(mutation responses are small)|
+
+Gravitino's Iceberg mutations (create/drop/update) are typically sub-second,
+which makes heartbeat-based liveness and crash reconciliation unnecessary
+complexity. The simpler reserve-execute-finalize model is sufficient. If
+long-running operations are added in the future (e.g., async purge), heartbeat
+support can be added to the SPI without breaking existing implementations.
+
+---
+
+## 8. Implementation Phases
+
+### Phase 1: SPI and In-Memory Store
+
+- Define `IdempotencyStore` interface and `IdempotencyRecord` in
+ `iceberg/iceberg-common`.
+- Implement `InMemoryIdempotencyStore` using Caffeine cache.
+- Wire into endpoint operations with `@HeaderParam("Idempotency-Key")`.
+- Advertise `idempotency-key-lifetime` in `GET /v1/config`.
+- Unit tests for all mutation endpoints.
+
+### Phase 2: JDBC Store
+
+- Add `iceberg_idempotency_key_meta` table schema for H2, MySQL, and
PostgreSQL.
+- Implement `JdbcIdempotencyStore` using Gravitino's existing connection pool.
+- Background cleanup task for expired records.
+- Integration tests with testcontainers.
+
+### Phase 3: Observability (Out of Scope for Now)
+
+Deferred until the SPI and JDBC store land; not required for the initial
+implementation:
+
+- Metrics: cache hit rate, replay count, store latency, in-memory eviction
rate.
+
+---
+
+## 9. Testing Strategy
+
+- **Unit tests**: Store SPI implementations (in-memory and JDBC), UUIDv7 /
+ RFC 9562 validation (reject UUIDv4, wrong length, bad variant), replay logic,
+ terminal vs. retryable error handling, in-memory size-based eviction, and
+ `purgeExpired` batching.
+- **Integration tests**: Multi-threaded concurrent reserve/finalize on the same
+ key, TTL-based expiry, cleanup task removing only expired rows, JDBC store
+ with H2.
+- **E2E tests**: With `idempotency-enabled=false` the header is ignored; with
+ it enabled, retry with same key returns cached response, different key
+ executes fresh, invalid key returns 400, and `idempotency-key-lifetime` is
+ advertised in `GET /v1/config`.