/**************************************************************************** * 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 lockCache = new ConcurrentHashMap<>(); /** * WorkflowEvent listener that manages the lock cache based on document * lifecycle events. *

*

*

* 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; } }