274 lines
No EOL
10 KiB
Java
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;
|
|
}
|
|
} |