> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flowwarden.io/llms.txt
> Use this file to discover all available pages before exploring further.

# LockService SPI

> Distributed lock backing DeploymentMode.SINGLE_LEADER — contract, default backends, and how to plug in your own

`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](/guides/deployment-modes#single_leader). This page documents the SPI contract and the available implementations.

## When the SPI is exercised

| `deploymentMode`            | What runs                                                                                                                                                            |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ALL_INSTANCES` *(default)* | The framework uses the [no-op `LockService`](#no-op-default), and `tryAcquire` always returns `true`. No coordination, no Lock backend traffic.                      |
| `SINGLE_LEADER`             | The 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

```java theme={null}
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

<Warning>
  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.
</Warning>

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:

```java theme={null}
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`:

| Implementation     | When active                                   | Storage                                                                                                                                                                                                                                   |
| ------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MongoLockService` | Default in both imperative and reactive modes | The `_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`](/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](/redis/configuration#storage-layout) for the storage layout.

## No-op default

`LockService.noOp()` returns a shared singleton that always grants the lock and reports no active leader:

```java theme={null}
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.

```java theme={null}
@Configuration
public class CustomLockConfig {

    @Bean
    LockService lockService(/* your client */) {
        return new MyCustomLockService(/* ... */);
    }
}
```

<Note>
  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.
</Note>

### Testing the contract

If you write a custom `LockService`, run it against the contract tests published in `flowwarden-stream-core-testkit`:

```xml theme={null}
<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

<CardGroup cols={2}>
  <Card title="Deployment Modes" icon="sitemap" href="/guides/deployment-modes">
    User-facing semantics of `ALL_INSTANCES` vs `SINGLE_LEADER`.
  </Card>

  <Card title="Redis backend" icon="database" href="/redis">
    Drop-in Redis implementations of `LockService` and `CheckpointStore`.
  </Card>

  <Card title="CheckpointStore SPI" icon="bookmark" href="/reference/checkpoint-store">
    The sister SPI that backs `@Checkpoint`. Often paired with `SINGLE_LEADER` for at-least-once recovery.
  </Card>

  <Card title="DlqStore SPI" icon="trash" href="/reference/dlq-store">
    Failed-event persistence for the `@DeadLetterQueue` annotation.
  </Card>

  <Card title="StreamMetricsProvider SPI" icon="chart-line" href="/reference/stream-metrics-provider">
    Fire-and-forget callbacks for lifecycle / leadership / checkpoint events.
  </Card>

  <Card title="Actuator" icon="heart-pulse" href="/reference/actuator">
    Inspect lock ownership at runtime via `/actuator/flowwarden`.
  </Card>
</CardGroup>
