DocumentTemplateWebDavLockManager.java

package com.taxonomy.templates;

import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

/**
 * Bounded in-process WebDAV edit locks.
 *
 * <p>Locks improve the Word editing experience; Git per-template expected-version checks
 * remain the durable lost-update protection if a process restarts and its locks expire.</p>
 */
@Component
public class DocumentTemplateWebDavLockManager {

    static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(15);
    static final Duration MAX_TIMEOUT = Duration.ofHours(1);

    private final Map<String, TemplateLock> locks = new HashMap<>();
    private final Clock clock;

    public DocumentTemplateWebDavLockManager() {
        this(Clock.systemUTC());
    }

    DocumentTemplateWebDavLockManager(Clock clock) {
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public synchronized TemplateLock acquire(
            String resource,
            String owner,
            String currentCommit,
            Duration requestedTimeout,
            String refreshToken) {

        purgeExpired();
        TemplateLock existing = locks.get(resource);
        Duration timeout = boundedTimeout(requestedTimeout);
        Instant expiresAt = clock.instant().plus(timeout);

        if (existing != null) {
            if (refreshToken != null
                    && existing.token().equals(normalizeToken(refreshToken))
                    && existing.owner().equals(owner)) {
                TemplateLock refreshed = new TemplateLock(
                        existing.resource(),
                        existing.token(),
                        existing.owner(),
                        existing.baseCommit(),
                        existing.currentCommit(),
                        expiresAt);
                locks.put(resource, refreshed);
                return refreshed;
            }
            throw new LockConflictException(
                    "Template is already locked by another edit session");
        }

        TemplateLock created = new TemplateLock(
                resource,
                "opaquelocktoken:" + UUID.randomUUID(),
                owner,
                currentCommit,
                currentCommit,
                expiresAt);
        locks.put(resource, created);
        return created;
    }

    public synchronized TemplateLock require(
            String resource,
            String token,
            String owner) {
        purgeExpired();
        return requireExisting(resource, token, owner);
    }

    public synchronized TemplateLock find(String resource) {
        purgeExpired();
        return locks.get(resource);
    }

    /**
     * Run one versioned write and advance its lock without a post-commit failure window.
     *
     * <p>The manager monitor is deliberately held across the bounded template validation
     * and Git commit. No competing lock operation can expire or replace the lease during
     * that critical section. After a successful write the existing lease is retained and,
     * when necessary, extended to the normal refresh interval rather than silently changed
     * to the maximum one-hour timeout.</p>
     */
    public synchronized <T> T executeWrite(
            String resource,
            String token,
            String owner,
            String expectedWithoutLock,
            LockedWrite<T> operation) throws IOException {
        Objects.requireNonNull(operation, "operation");
        purgeExpired();

        TemplateLock active = locks.get(resource);
        String expected = expectedWithoutLock;
        if (active != null) {
            active = requireExisting(resource, token, owner);
            expected = active.currentCommit();
        } else if (token != null && !token.isBlank()) {
            throw new LockConflictException(
                    "The supplied WebDAV lock token is no longer active");
        }

        LockedWriteResult<T> result = Objects.requireNonNull(
                operation.execute(expected), "locked write result");
        if (active != null) {
            Instant refreshedUntil = later(
                    active.expiresAt(), clock.instant().plus(DEFAULT_TIMEOUT));
            locks.put(resource, new TemplateLock(
                    active.resource(),
                    active.token(),
                    active.owner(),
                    active.baseCommit(),
                    Objects.requireNonNull(result.currentCommit(), "currentCommit"),
                    refreshedUntil));
        }
        return result.value();
    }

    public synchronized TemplateLock advance(
            String resource,
            String token,
            String owner,
            String commitId) {
        TemplateLock existing = require(resource, token, owner);
        TemplateLock advanced = new TemplateLock(
                existing.resource(),
                existing.token(),
                existing.owner(),
                existing.baseCommit(),
                commitId,
                existing.expiresAt());
        locks.put(resource, advanced);
        return advanced;
    }

    public synchronized void release(String resource, String token, String owner) {
        TemplateLock existing = require(resource, token, owner);
        locks.remove(existing.resource());
    }

    private TemplateLock requireExisting(
            String resource,
            String token,
            String owner) {
        TemplateLock existing = locks.get(resource);
        if (existing == null) {
            throw new LockConflictException(
                    "No active WebDAV lock exists for this template");
        }
        String normalized = normalizeToken(token);
        if (!existing.token().equals(normalized) || !existing.owner().equals(owner)) {
            throw new LockConflictException(
                    "WebDAV lock token or owner does not match");
        }
        return existing;
    }

    private void purgeExpired() {
        Instant now = clock.instant();
        locks.values().removeIf(lock -> !lock.expiresAt().isAfter(now));
    }

    private static Instant later(Instant first, Instant second) {
        return first.isAfter(second) ? first : second;
    }

    private static Duration boundedTimeout(Duration requested) {
        if (requested == null || requested.isNegative() || requested.isZero()) {
            return DEFAULT_TIMEOUT;
        }
        return requested.compareTo(MAX_TIMEOUT) > 0 ? MAX_TIMEOUT : requested;
    }

    static String normalizeToken(String token) {
        if (token == null) {
            return null;
        }
        String normalized = token.strip();
        if (normalized.startsWith("<") && normalized.endsWith(">")) {
            normalized = normalized.substring(1, normalized.length() - 1);
        }
        return normalized;
    }

    @FunctionalInterface
    public interface LockedWrite<T> {
        LockedWriteResult<T> execute(String expectedCommit) throws IOException;
    }

    public record LockedWriteResult<T>(T value, String currentCommit) {
    }

    public record TemplateLock(
            String resource,
            String token,
            String owner,
            String baseCommit,
            String currentCommit,
            Instant expiresAt) {
    }

    public static final class LockConflictException extends RuntimeException {
        LockConflictException(String message) {
            super(message);
        }
    }
}