Interface CurationLockService

All Known Implementing Classes:
CurationLockServiceImpl

public interface CurationLockService
Advisory, steal-able claims on a dataset's curation.

🛑 Advisory. The correctness guarantee for concurrent curation writes is the optimistic-concurrency token — the curation commit checks baseline.lastModified and returns 409 when the dataset moved. This lock exists so that 409 rarely fires and so a curator can see who else is working. Nothing here may become the thing that makes concurrent writes safe.

🛑 No audit events. Any audit event sets curationDetails.lastUpdated, which is that same token, so taking a lock would 409 every draft in flight on the dataset. The row carries stolenFrom / stolenAt and is its own record.

As with the rest of the curation surface, the holder identity is passed in rather than read from the security context: curation reaches Gemma through the agent, so the principal is normally not the person holding the lock.

  • Field Details

    • DEFAULT_TTL_MINUTES

      static final int DEFAULT_TTL_MINUTES
      Default lease length. Refreshed by curator activity.
      See Also:
  • Method Details

    • acquire

      CurationLock acquire(Investigation ee, String lockedBy, boolean steal, int ttlMinutes)
      Take or refresh the lock.
      Parameters:
      steal - permit taking a lock another curator currently holds. Always available to callers by design — there is no unlock ceremony to forget, and a steal destroys nothing, since the displaced curator's DRAFT is a separate row.
      Returns:
      the granted lock
      Throws:
      CurationLockService.CurationLockedException - if the lock is held by someone else and steal is false
    • acquire

      CurationLock acquire(Investigation ee, String lockedBy, boolean steal, int ttlMinutes, @Nullable String runId, @Nullable String agentName)
      As acquire(Investigation, String, boolean, int), recording WHAT is taking the lock as well as who it is for.

      runId / agentName are null for a person, which is how a reader tells a curator from a job. See CurationLock.getRunId() for why this is stored rather than derived.

    • refresh

      Optional<CurationLock> refresh(Investigation ee, String heldBy, int ttlMinutes)
      Extend the caller's existing lease without taking one they do not hold. Called on every draft autosave, so working holds the lock and walking away releases it.
      Returns:
      the refreshed lock, or empty if heldBy does not hold it — a refresh must never quietly become an acquire, or an autosave would steal a lock the curator never asked for
    • release

      boolean release(Investigation ee, String heldBy)
      Release the lock if heldBy holds it.
      Returns:
      whether a lock was released
    • forceRelease

      boolean forceRelease(Investigation ee)
      Release whoever holds it. For administrators.
    • current

      The current holder, or empty when the dataset is free.

      An expired row reads as empty rather than as a holder: nothing sweeps expiry, so "is it locked" has to mean "is there an unexpired claim".

    • current

      Map<Long, CurationLock> current(Collection<Long> investigationIds)
      The current holders of many datasets, keyed by dataset id, in ONE query.

      Exists because the question is asked about a LIST: the curation queue pages up to 1000 rows, and asking per row is 1000 round-trips to paint one screen. A dataset nobody holds is simply absent from the map rather than mapped to null.

      Same expiry rule as current(Investigation) — a lapsed claim is not a holder — so a caller can treat presence in this map as "held right now" without re-checking dates.

    • isHeldBy

      boolean isHeldBy(Investigation ee, @Nullable String username)
      Whether username holds an unexpired lock. This is what sign-off checks.