Prepare Key
PrepareKey<V> provides application-level reservation for keys such as usernames or SKUs that contend across aggregates. A MongoDB, Redis, or other adapter atomically owns the mapping from key to current value. PrepareKey is not part of EventStore and is not a database transaction spanning EventStore and an external store.
Declaration and assembly
@PreparableKey(name = "username")
interface UsernamePrepareKey : PrepareKey<UsernameIndex>The Starter scans application base packages and wow.prepare.base-packages for interfaces annotated with @PreparableKey. The interface must expose a concrete value type through PrepareKey<V>. A blank annotation name falls back to the interface simple name; the Spring bean name also uses the interface simple name.
The default proxy factory can create a backend delegate only when a PrepareKeyFactory exists. Backend selection and enablement live in the Core Configuration Reference and the MongoDB / Redis extension pages.
Operation contract
| Operation | Success condition | Meaning of false |
|---|---|---|
prepare(key, value) | The key can be reserved for this value | The key is occupied |
get(key) | A record exists and its PreparedValue is not expired | Empty means absent or expired |
getValue(key) | A record exists | Returns value and ttlAt, even when expired |
rollback(key) | Delete the current record unconditionally | No record to delete |
rollback(key, value) | Delete only when current value matches | Key absent or value mismatch |
reprepare(key, old, new) | Current value equals old and is replaced with new | Key absent or old value mismatch |
The concrete PrepareKeyFactory owns atomicity. The interface alone does not establish lock scope, isolation level, or cross-region consistency.
TTL
PreparedValue stores a value and absolute expiration time ttlAt in Unix epoch milliseconds:
val forever = value.toForever()
val temporary = value.toTtlAt(System.currentTimeMillis() + 5 * 60_000)get filters expired values at the client-interface layer; the backend is responsible for allowing an expired key to be prepared again. TTL depends on caller/backend clocks and is not a precise business timer. Permanent values use framework constant TTL_FOREVER; do not copy its numeric value.
Exact usingPrepare boundary
return usernamePrepareKey.usingPrepare(command.username, index) { prepared ->
require(prepared) { "username is already reserved" }
Registered(command.username).toMono()
}The flow is:
- invoke
prepare; - pass the Boolean result to
then, whether true or false; - only when
prepared == trueandthenterminates with an error, invoke conditional rollback; - after rollback completes, propagate the original error; if rollback itself fails, the reactive chain propagates that rollback error.
The current implementation uses onErrorResume and does not register automatic rollback for cancellation. A successful result also does not “commit” another record: the reservation remains until explicit rollback, reprepare, or TTL expiry. “Transaction-like” therefore refers only to conditional release on the error path and must not be expanded into a cross-storage transaction guarantee.
Change a key
prepareKey.reprepare(
oldKey = state.username,
oldValue = currentIndex,
newKey = command.newUsername,
newValue = currentIndex,
)The default composition first executes prepare(newKey), then conditional rollback(oldKey, oldValue):
- if the new key is occupied, return
falseand retain the old key; - if old key/value does not match, throw
IllegalStateExceptionand use the error path to attempt release of the newly reserved key; - if old and new keys are equal, reject immediately; use the same-key
reprepareoverload instead.
These are two backend operations composed with compensation, not an indivisible cross-key transaction. Recovery still needs backend/TTL evidence when the process crashes, times out, or is cancelled between the two operations.
Use with aggregate commands
PrepareKey fits a command decision that must reserve an external unique key, but EventStore append may fail afterward. A permanent reservation needs an explicit release/reconciliation design for command failure. A TTL reservation needs evidence that expiry and reacquisition are acceptable to the business.
Do not collapse request-ID idempotency, EventStore version concurrency, and PrepareKey uniqueness into one mechanism:
- request ID identifies a repeated command request;
- aggregate version protects one aggregate event stream;
- PrepareKey coordinates aggregates contending for one application key.
Verification
./gradlew :wow-core:test --tests "me.ahoo.wow.infra.prepare.PrepareKeyTest"
./gradlew :wow-core:test --tests "me.ahoo.wow.infra.prepare.proxy.PrepareKeyProxyAndMetadataTest"
./gradlew :wow-mongo:integrationTest --tests "*PrepareKey*"
./gradlew :wow-redis:integrationTest --tests "*PrepareKey*"The last two require their infrastructure. Applications should additionally test crash windows, TTL/clock behavior, and reconciliation after failure.