/**************************************************************************** * 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. *
* 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. *
* 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. *
* Thread Safety: 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
*
* 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).
*
* 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.
*
* 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.
*
* Note: 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.
*
* 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;
}
}
*
*