office-dynamixs-ai/_OLD/cognition/CognitoLockHandler.java
2026-02-09 11:14:10 +01:00

274 lines
No EOL
10 KiB
Java

/****************************************************************************
* Copyright (c) 2022-2025 Imixs Software Solutions GmbH and others.
* https://www.imixs.com
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0
*
* This Source Code may also be made available under the terms of the
* GNU General Public License, version 2 or later (GPL-2.0-or-later),
* which is available at https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
****************************************************************************/
package ai.dynamixs.cognition;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.imixs.workflow.exceptions.PluginException;
import org.imixs.workflow.faces.data.WorkflowEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.interceptor.Interceptor;
/**
* The CognitoLockHandler is an application scoped CDI bean that holds a list of
* workitem uniqueIds currently in edit mode. This caching mechanism is used by
* the {@code CognitoEventService} to avoid optimistic lock exceptions.
* <p>
* The IDs are stored in the lock cache. The CognitoEventService looks up an ID
* in this cache before updating a workitem in a safe way.
* <p>
* It may happen that a user switches into edit mode
* ({@link WorkflowEvent#DOCUMENT_CHANGED}) without updating the workitem. For
* this reason a locked ID will be released after {@value #LOCK_TIMEOUT_SECONDS}
* seconds automatically.
* <p>
* <strong>Thread Safety:</strong> This implementation uses a
* {@link ConcurrentHashMap} to ensure thread-safe operations in a
* multi-user-environment where the same workitem can be opened by different
* users in parallel.
*
* @author rsoika
* @version 1.0
*/
@ApplicationScoped
public class CognitoLockHandler {
private static final Logger logger = Logger.getLogger(CognitoLockHandler.class.getName());
/**
* Lock timeout in seconds. After this duration, a lock is considered stale and
* will be automatically released.
*/
public static final long LOCK_TIMEOUT_SECONDS = 120;
/**
* Thread-safe cache storing workitem uniqueIds mapped to their lock timestamp.
* The timestamp is used to determine if a lock has expired.
*/
private final Map<String, Instant> lockCache = new ConcurrentHashMap<>();
/**
* WorkflowEvent listener that manages the lock cache based on document
* lifecycle events.
* <p>
* <ul>
* <li>{@link WorkflowEvent#DOCUMENT_CHANGED}: Adds the workitem's uniqueID to
* the lock cache (lock operation)</li>
* <li>{@link WorkflowEvent#DOCUMENT_AFTER_SAVE}: Removes the workitem's
* uniqueID from the lock cache (unlock operation)</li>
* </ul>
* <p>
* This observer is executed with {@link Interceptor.Priority#LIBRARY_BEFORE}
* priority to ensure it runs before other observers.
*
* @param workflowEvent the workflow event containing the workitem and event
* type
* @throws PluginException if an error occurs during event processing
*/
public void onWorkflowEvent(@Observes WorkflowEvent workflowEvent)
throws PluginException {
int eventType = workflowEvent.getEventType();
String uniqueId = extractUniqueId(workflowEvent);
if (uniqueId == null || uniqueId.isEmpty()) {
logger.log(Level.FINE, "Skipping lock operation - workitem has no uniqueId");
return;
}
// Lock the workitem when user enters edit mode
if (WorkflowEvent.WORKITEM_CHANGED == eventType) {
lock(uniqueId);
logger.log(Level.INFO, "Workitem locked: uniqueId={0}, active locks={1}",
new Object[] { uniqueId, lockCache.size() });
}
// Unlock the workitem after successful save
if (WorkflowEvent.WORKITEM_AFTER_PROCESS == eventType) {
boolean wasLocked = unlock(uniqueId);
if (wasLocked) {
logger.log(Level.INFO, "Workitem unlocked after process: uniqueId={0}, remaining locks={1}",
new Object[] { uniqueId, lockCache.size() });
} else {
logger.log(Level.INFO, "Workitem was not locked (possibly expired): uniqueId={0}",
uniqueId);
}
}
}
/**
* Checks if the given workitem uniqueID is currently locked (in edit mode).
* <p>
* This method also performs cleanup of stale locks. If the lock for the given
* ID has exceeded the timeout ({@value #LOCK_TIMEOUT_SECONDS} seconds), it will
* be automatically removed and this method returns {@code false}.
*
* @param id the unique identifier of the workitem to check
* @return {@code true} if the workitem is currently locked and the lock has not
* expired, {@code false} otherwise
*/
public boolean isLocked(String id) {
if (id == null || id.isEmpty()) {
logger.log(Level.FINE, "isLocked called with null or empty id - returning false");
return false;
}
Instant lockTimestamp = lockCache.get(id);
if (lockTimestamp == null) {
logger.log(Level.FINE, "Workitem not locked: uniqueId={0}", id);
return false;
}
// Check if lock has expired
if (isLockExpired(lockTimestamp)) {
logger.log(Level.INFO, "Lock expired and removed: uniqueId={0}, locked since={1}",
new Object[] { id, lockTimestamp });
lockCache.remove(id);
return false;
}
long lockedSeconds = java.time.Duration.between(lockTimestamp, Instant.now()).getSeconds();
logger.log(Level.FINE, "Workitem is locked: uniqueId={0}, locked for {1}s",
new Object[] { id, lockedSeconds });
return true;
}
/**
* Adds a workitem uniqueID to the lock cache with the current timestamp.
* <p>
* If the ID is already in the cache, the timestamp will be updated (lock
* refresh).
*
* @param id the unique identifier of the workitem to lock
*/
public void lock(String id) {
if (id == null || id.isEmpty()) {
logger.log(Level.WARNING, "Attempted to lock with null or empty id - operation ignored");
return;
}
Instant previousLock = lockCache.put(id, Instant.now());
if (previousLock != null) {
logger.log(Level.FINE, "Lock refreshed: uniqueId={0}, previous lock from={1}",
new Object[] { id, previousLock });
} else {
logger.log(Level.FINE, "New lock created: uniqueId={0}", id);
}
// Opportunistic cleanup of expired locks
cleanupExpiredLocks();
}
/**
* Removes a workitem uniqueID from the lock cache.
*
* @param id the unique identifier of the workitem to unlock
* @return {@code true} if the workitem was locked and is now unlocked,
* {@code false} if the workitem was not locked
*/
public boolean unlock(String id) {
if (id == null || id.isEmpty()) {
logger.log(Level.WARNING, "Attempted to unlock with null or empty id - operation ignored");
return false;
}
Instant removed = lockCache.remove(id);
if (removed != null) {
long lockedDuration = java.time.Duration.between(removed, Instant.now()).getSeconds();
logger.log(Level.FINE, "Lock removed: uniqueId={0}, was locked for {1}s",
new Object[] { id, lockedDuration });
return true;
}
return false;
}
/**
* Returns the current number of active locks in the cache.
* <p>
* <strong>Note:</strong> This count may include expired locks that have not yet
* been cleaned up.
*
* @return the number of entries in the lock cache
*/
public int getLockCount() {
return lockCache.size();
}
/**
* Removes all expired locks from the cache.
* <p>
* This method is called opportunistically during lock operations to prevent
* unbounded cache growth.
*/
public void cleanupExpiredLocks() {
int initialSize = lockCache.size();
lockCache.entrySet().removeIf(entry -> {
boolean expired = isLockExpired(entry.getValue());
if (expired) {
logger.log(Level.FINE, "Cleaning up expired lock: uniqueId={0}, locked since={1}",
new Object[] { entry.getKey(), entry.getValue() });
}
return expired;
});
int removed = initialSize - lockCache.size();
if (removed > 0) {
logger.log(Level.INFO, "Expired locks cleanup completed: removed={0}, remaining={1}",
new Object[] { removed, lockCache.size() });
}
}
/**
* Checks if a lock timestamp has exceeded the timeout threshold.
*
* @param lockTimestamp the timestamp when the lock was created
* @return {@code true} if the lock is expired, {@code false} otherwise
*/
private boolean isLockExpired(Instant lockTimestamp) {
return lockTimestamp.plusSeconds(LOCK_TIMEOUT_SECONDS).isBefore(Instant.now());
}
/**
* Extracts the uniqueId from the workitem contained in the workflow event.
*
* @param workflowEvent the workflow event
* @return the uniqueId of the workitem, or {@code null} if not available
*/
private String extractUniqueId(WorkflowEvent workflowEvent) {
if (workflowEvent == null || workflowEvent.getWorkitem() == null) {
logger.log(Level.WARNING, "WorkflowEvent or workitem is null - cannot extract uniqueId");
return null;
}
String uniqueId = workflowEvent.getWorkitem().getUniqueID();
if (uniqueId == null || uniqueId.isEmpty()) {
logger.log(Level.FINE, "Workitem has no uniqueId (possibly a new document)");
}
return uniqueId;
}
}