Skip to main content
LockService is the SPI behind DeploymentMode.SINGLE_LEADER. When you mark a @ChangeStream as single-leader, FlowWarden’s leader-election coordinator uses this SPI to ensure exactly one application instance processes events for that stream at any time, with automatic failover when the current leader stops renewing its lock. For the user-facing description of single-leader semantics, see the Deployment Modes guide. This page documents the SPI contract and the available implementations.

When the SPI is exercised

deploymentModeWhat runs
ALL_INSTANCES (default)The framework uses the no-op LockService, and tryAcquire always returns true. No coordination, no Lock backend traffic.
SINGLE_LEADERThe framework’s LeaderElectionCoordinator periodically calls tryAcquire / renew against the configured LockService bean, and release on graceful shutdown.
A single LockService bean serves every SINGLE_LEADER stream in the application — each stream gets its own logical lock keyed by streamName.

Interface

public interface LockService {

    boolean tryAcquire(String streamName, String instanceId, Duration ttl);
    boolean renew     (String streamName, String instanceId, Duration ttl);
    void    release   (String streamName, String instanceId);

    Optional<LockState> getLockState(String streamName);

    /** Convenience: returns the instanceId of the current leader, if any. */
    default Optional<String> getCurrentLeader(String streamName) {
        return getLockState(streamName).map(LockState::instanceId);
    }

    /** Always-grants implementation, used internally for ALL_INSTANCES streams. */
    static LockService noOp();
}

Lifecycle contract

  • tryAcquire must succeed if the lock does not exist, has expired, or is already owned by instanceId. It must be atomic against concurrent callers — two instances racing to acquire the same streamName must end with exactly one winner.
  • renew must extend the lock’s expiration only if instanceId is still the current owner. It must fail (return false) if the lock has expired or is held by a different instance.
  • release removes the lock if and only if it is owned by instanceId. It is a no-op otherwise — releasing a lock you don’t own must not affect another instance’s grip on it.

Error contract for hot-path methods

Implementations of tryAcquire and renew must convert transient backend errors into a false return rather than propagating exceptions. The leader-election coordinator treats propagated exceptions defensively (a renew throw is treated as renew() == false and triggers the same fail-stop path), but this is a safety net for non-conformant implementations, not the contract. A propagated cause is only logged at WARN, losing the structured signal that a false return provides.
Transient errors in this context are: network timeout, primary stepdown / failover, command timeout, lost connection, replica-set re-election, etc. — anything that is a recoverable infrastructure hiccup rather than a programming bug.

LockState record

Returned by getLockState and consumed by the FlowWarden Reporter / Console Leadership panel, plus any external consumer that needs to inspect lock ownership:
public record LockState(
        String streamName,
        String instanceId,
        Instant acquiredAt,
        Instant expiresAt) {}
Implementations must return Optional.empty() for missing or expired locks — never a LockState whose expiresAt is in the past.

Provided implementations

The framework ships with one MongoDB-backed implementation auto-configured by both ImperativeFlowWardenAutoConfiguration and ReactiveFlowWardenAutoConfiguration:
ImplementationWhen activeStorage
MongoLockServiceDefault in both imperative and reactive modesThe _fw_locks collection. tryAcquire uses findOneAndUpdate with a filter on either instanceId == self or expiresAt < now, falling back to insertOne on first acquisition. The collection name is internal — not configurable.
For a Redis-backed deployment, the flowwarden-redis satellite ships RedisLockService (blocking, auto-configured) and ReactiveRedisLockService (reactive, opt-in). Both use Redis Hashes with Lua scripts for the atomic compare-and-set semantics. See Redis Configuration for the storage layout.

No-op default

LockService.noOp() returns a shared singleton that always grants the lock and reports no active leader:
LockService noop = LockService.noOp();
noop.tryAcquire("anything", "anyone", Duration.ofSeconds(30));  // true
noop.renew     ("anything", "anyone", Duration.ofSeconds(30));  // true
noop.getLockState("anything");                                  // Optional.empty()
This is what the framework uses internally for ALL_INSTANCES streams and what you should reach for in tests that want to bypass coordination entirely without standing up a Mongo or Redis container.

Providing a custom implementation

The auto-configured beans are guarded by @ConditionalOnMissingBean, so any @Bean LockService you declare wins. This is how you’d plug in a backend that ships nowhere in the FlowWarden codebase — Consul, etcd, ZooKeeper, JDBC, a homegrown lock service, etc.
@Configuration
public class CustomLockConfig {

    @Bean
    LockService lockService(/* your client */) {
        return new MyCustomLockService(/* ... */);
    }
}
Single-leader coordination is independent from checkpoint storage. You can mix backends — for example, a Consul LockService paired with the default MongoCheckpointStore — by declaring only the bean you want to override. The framework wires whatever beans are present.

Testing the contract

If you write a custom LockService, run it against the contract tests published in flowwarden-stream-core-testkit:
<dependency>
    <groupId>io.flowwarden</groupId>
    <artifactId>flowwarden-stream-core-testkit</artifactId>
    <scope>test</scope>
</dependency>
Extend LockServiceContractTest and implement the abstract lockService() factory method. The contract tests cover acquire / renew / release semantics, expiration, concurrent acquisition, and the instanceId ownership invariants — the same suite the built-in MongoDB and Redis implementations are validated against.

See Also

Deployment Modes

User-facing semantics of ALL_INSTANCES vs SINGLE_LEADER.

Redis backend

Drop-in Redis implementations of LockService and CheckpointStore.

CheckpointStore SPI

The sister SPI that backs @Checkpoint. Often paired with SINGLE_LEADER for at-least-once recovery.

DlqStore SPI

Failed-event persistence for the @DeadLetterQueue annotation.

StreamMetricsProvider SPI

Fire-and-forget callbacks for lifecycle / leadership / checkpoint events.

Actuator

Inspect lock ownership at runtime via /actuator/flowwarden.