Leader lease only
It reduces concurrent workers but cannot reject a resumed stale worker.
Leader election only selects who may attempt the work. Redis must issue an ordering token and PostgreSQL must decide whether a delayed mutation is still current.
01 · Problem framing
Lease expiry does not stop the old worker thread. After a GC pause, network partition, or long I/O, the old worker can resume and write after the takeover.
It reduces concurrent workers but cannot reject a resumed stale worker.
Two differently named jobs can still mutate the same business resource.
A new request can duplicate an earlier request whose result is unknown.
| Mutual exclusion | Redis leader and resource leases | Reduce concurrent execution at one moment. |
|---|---|---|
| Failover | Lease expiry and reacquisition | Allow a new worker to take over. |
| Stale-write rejection | Fencing token + PostgreSQL | Reject an older generation at commit. |
| Replay safety | OperationId + idempotency | Look up and retry the same external operation. |
| Durable completion | Exposed transaction + outbox | Commit business state and follow-up work together. |
02 · Solution design
No single component provides all guarantees. Coordination, durable mutation, and external outcome recovery remain separate.
The owner token is an opaque identifier with no ordering semantics.
Redis lease state can diverge from PostgreSQL business state.
Both the unknown first request and the retry may be applied.
Every takeover receives a higher generation.
Mutate only when `incomingFence > lastAcceptedFence`.
Query the same request instead of guessing the result.
03 · Architecture
No single component provides all guarantees. Coordination, durable mutation, and external outcome recovery remain separate.
Scenario selection · SAFE/UNSAFE isolation · authorization
Leader lease · resource lease · fencing-token allocation
Current-state recheck · conditional mutation · checkpoint/execution/outbox
Provider call · receipt · reconciliation
04 · Interactive flow
Switch the same scenario between SAFE and UNSAFE to find the exact step where the final state diverges.
05 · Source mapping
The visible steps are reconstructed from the actual services and deterministic scenario implementation.
JobRunCoordinatorAcquires and releases leader and resource leases in order.
coordination/JobRunCoordinator.ktRedisJobFencingLeaseAdapterUses Redis Lua to allocate monotonic fencing tokens.
coordination/redis/RedisJobFencingLeaseAdapter.ktFencedJobExecutionServiceRechecks tenant, region, version, and namespace before mutation.
execution/FencedJobExecutionService.ktJobSafetyRepositoriesOwns the conditional mutation, checkpoint, execution, and outbox writes.
persistence/JobSafetyRepositories.ktOutboxEffectWorkerCalls the provider outside the transaction and persists uncertain outcomes for reconciliation.
effect/OutboxEffectWorker.ktJobSafetyScenarioServiceProduces deterministic SAFE and UNSAFE results for all six scenarios.
scenario/JobSafetyScenarioService.ktLeaseOverrunScenarioTestReject token 41 and preserve the token-42 resultFencedMutationPostgresIntegrationTestReject a stale conditional mutation in PostgreSQLJobSafetyEndToEndIntegrationTestCommit business state, checkpoint, execution, and outbox atomicallyNonFenceableEffectScenarioTestAvoid duplicate effects with a stable OperationId and reconciliationUnsafeJobSafetyControllerConditionTestIsolate the UNSAFE API behind both profile and property06 · Run the example
The example uses JDK 25, a PostgreSQL 18-compatible server, and a Redis 8-compatible server. Run the UNSAFE API only as an isolated comparison.
07 · Boundaries
PostgreSQL rejects a conditional mutation carrying an old fencing token.
The transaction reloads tenant, region, version, and namespace state.
The worker queries the original OperationId and records a receipt.
Exactly-once external effects cannot be guaranteed; manual review or compensation is required.
Preserve the counter in backups or advance the namespace epoch.
The example uses SchemaUtils at startup; production needs Flyway or Liquibase.