diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLock.java
new file mode 100644
index 00000000..157b0dd7
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLock.java
@@ -0,0 +1,330 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import java.time.Instant;
+import com.kingsrook.qqq.backend.core.model.data.QField;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
+
+
+/*******************************************************************************
+ ** QRecord Entity for ProcessLock table
+ *******************************************************************************/
+public class ProcessLock extends QRecordEntity
+{
+ public static final String TABLE_NAME = "processLock";
+
+ @QField(isEditable = false, isPrimaryKey = true)
+ private Integer id;
+
+ @QField(isEditable = false)
+ private Instant createDate;
+
+ @QField(isEditable = false)
+ private Instant modifyDate;
+
+ @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
+ private String key;
+
+ @QField(possibleValueSourceName = ProcessLockType.TABLE_NAME)
+ private Integer processLockTypeId;
+
+ @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
+ private String holder;
+
+ @QField()
+ private Instant checkInTimestamp;
+
+ @QField()
+ private Instant expiresAtTimestamp;
+
+
+
+ /*******************************************************************************
+ ** Default constructor
+ *******************************************************************************/
+ public ProcessLock()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor that takes a QRecord
+ *******************************************************************************/
+ public ProcessLock(QRecord record)
+ {
+ populateFromQRecord(record);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for id
+ *******************************************************************************/
+ public Integer getId()
+ {
+ return (this.id);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for id
+ *******************************************************************************/
+ public void setId(Integer id)
+ {
+ this.id = id;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for id
+ *******************************************************************************/
+ public ProcessLock withId(Integer id)
+ {
+ this.id = id;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for createDate
+ *******************************************************************************/
+ public Instant getCreateDate()
+ {
+ return (this.createDate);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for createDate
+ *******************************************************************************/
+ public void setCreateDate(Instant createDate)
+ {
+ this.createDate = createDate;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for createDate
+ *******************************************************************************/
+ public ProcessLock withCreateDate(Instant createDate)
+ {
+ this.createDate = createDate;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for modifyDate
+ *******************************************************************************/
+ public Instant getModifyDate()
+ {
+ return (this.modifyDate);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for modifyDate
+ *******************************************************************************/
+ public void setModifyDate(Instant modifyDate)
+ {
+ this.modifyDate = modifyDate;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for modifyDate
+ *******************************************************************************/
+ public ProcessLock withModifyDate(Instant modifyDate)
+ {
+ this.modifyDate = modifyDate;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for key
+ *******************************************************************************/
+ public String getKey()
+ {
+ return (this.key);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for key
+ *******************************************************************************/
+ public void setKey(String key)
+ {
+ this.key = key;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for key
+ *******************************************************************************/
+ public ProcessLock withKey(String key)
+ {
+ this.key = key;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for holder
+ *******************************************************************************/
+ public String getHolder()
+ {
+ return (this.holder);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for holder
+ *******************************************************************************/
+ public void setHolder(String holder)
+ {
+ this.holder = holder;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for holder
+ *******************************************************************************/
+ public ProcessLock withHolder(String holder)
+ {
+ this.holder = holder;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for checkInTimestamp
+ *******************************************************************************/
+ public Instant getCheckInTimestamp()
+ {
+ return (this.checkInTimestamp);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for checkInTimestamp
+ *******************************************************************************/
+ public void setCheckInTimestamp(Instant checkInTimestamp)
+ {
+ this.checkInTimestamp = checkInTimestamp;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for checkInTimestamp
+ *******************************************************************************/
+ public ProcessLock withCheckInTimestamp(Instant checkInTimestamp)
+ {
+ this.checkInTimestamp = checkInTimestamp;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for expiresAtTimestamp
+ *******************************************************************************/
+ public Instant getExpiresAtTimestamp()
+ {
+ return (this.expiresAtTimestamp);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for expiresAtTimestamp
+ *******************************************************************************/
+ public void setExpiresAtTimestamp(Instant expiresAtTimestamp)
+ {
+ this.expiresAtTimestamp = expiresAtTimestamp;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for expiresAtTimestamp
+ *******************************************************************************/
+ public ProcessLock withExpiresAtTimestamp(Instant expiresAtTimestamp)
+ {
+ this.expiresAtTimestamp = expiresAtTimestamp;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for processLockTypeId
+ *******************************************************************************/
+ public Integer getProcessLockTypeId()
+ {
+ return (this.processLockTypeId);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for processLockTypeId
+ *******************************************************************************/
+ public void setProcessLockTypeId(Integer processLockTypeId)
+ {
+ this.processLockTypeId = processLockTypeId;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for processLockTypeId
+ *******************************************************************************/
+ public ProcessLock withProcessLockTypeId(Integer processLockTypeId)
+ {
+ this.processLockTypeId = processLockTypeId;
+ return (this);
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockMetaDataProducer.java
new file mode 100644
index 00000000..3f10f86f
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockMetaDataProducer.java
@@ -0,0 +1,104 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
+
+
+/*******************************************************************************
+ ** MetaData producer for Process Locks "system"
+ *******************************************************************************/
+public class ProcessLockMetaDataProducer implements MetaDataProducerInterface
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
+ {
+ MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
+
+ ////////////////////////
+ // process lock table //
+ ////////////////////////
+ output.add(new QTableMetaData()
+ .withName(ProcessLock.TABLE_NAME)
+ .withFieldsFromEntity(ProcessLock.class)
+ .withIcon(new QIcon().withName("sync_lock"))
+ .withUniqueKey(new UniqueKey("processLockTypeId", "key"))
+ .withRecordLabelFormat("%s %s")
+ .withRecordLabelFields("processLockTypeId", "key")
+ .withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id", "processLockTypeId", "key")))
+ .withSection(new QFieldSection("data", new QIcon().withName("text_snippet"), Tier.T2, List.of("holder", "checkInTimestamp", "expiresAtTimestamp")))
+ .withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")))
+ );
+
+ /////////////////////////////
+ // process lock type table //
+ /////////////////////////////
+ output.add(new QTableMetaData()
+ .withName(ProcessLockType.TABLE_NAME)
+ .withFieldsFromEntity(ProcessLockType.class)
+ .withIcon(new QIcon().withName("lock"))
+ .withUniqueKey(new UniqueKey("name"))
+ .withRecordLabelFormat("%s")
+ .withRecordLabelFields("label")
+ .withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id", "name", "label")))
+ .withSection(new QFieldSection("data", new QIcon().withName("text_snippet"), Tier.T2, List.of("defaultExpirationSeconds")))
+ .withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")))
+ );
+
+ ///////////////////////////
+ // process lock type PVS //
+ ///////////////////////////
+ output.add(QPossibleValueSource.newForTable(ProcessLockType.TABLE_NAME));
+
+ /////////////////////////////////////////////////////
+ // join between process lock type and process lock //
+ /////////////////////////////////////////////////////
+ output.add(new QJoinMetaData()
+ .withLeftTable(ProcessLockType.TABLE_NAME)
+ .withRightTable(ProcessLock.TABLE_NAME)
+ .withInferredName()
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("name", "processLockTypeId"))
+ );
+
+ return output;
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockType.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockType.java
new file mode 100644
index 00000000..2a9e6209
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockType.java
@@ -0,0 +1,262 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import java.time.Instant;
+import com.kingsrook.qqq.backend.core.model.data.QField;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
+
+
+/*******************************************************************************
+ ** QRecord Entity for ProcessLockType table
+ *******************************************************************************/
+public class ProcessLockType extends QRecordEntity
+{
+ public static final String TABLE_NAME = "processLockType";
+
+ @QField(isEditable = false, isPrimaryKey = true)
+ private Integer id;
+
+ @QField(isEditable = false)
+ private Instant createDate;
+
+ @QField(isEditable = false)
+ private Instant modifyDate;
+
+ @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
+ private String name;
+
+ @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
+ private String label;
+
+ @QField()
+ private Integer defaultExpirationSeconds;
+
+
+
+ /*******************************************************************************
+ ** Default constructor
+ *******************************************************************************/
+ public ProcessLockType()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor that takes a QRecord
+ *******************************************************************************/
+ public ProcessLockType(QRecord record)
+ {
+ populateFromQRecord(record);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for id
+ *******************************************************************************/
+ public Integer getId()
+ {
+ return (this.id);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for id
+ *******************************************************************************/
+ public void setId(Integer id)
+ {
+ this.id = id;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for id
+ *******************************************************************************/
+ public ProcessLockType withId(Integer id)
+ {
+ this.id = id;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for createDate
+ *******************************************************************************/
+ public Instant getCreateDate()
+ {
+ return (this.createDate);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for createDate
+ *******************************************************************************/
+ public void setCreateDate(Instant createDate)
+ {
+ this.createDate = createDate;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for createDate
+ *******************************************************************************/
+ public ProcessLockType withCreateDate(Instant createDate)
+ {
+ this.createDate = createDate;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for modifyDate
+ *******************************************************************************/
+ public Instant getModifyDate()
+ {
+ return (this.modifyDate);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for modifyDate
+ *******************************************************************************/
+ public void setModifyDate(Instant modifyDate)
+ {
+ this.modifyDate = modifyDate;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for modifyDate
+ *******************************************************************************/
+ public ProcessLockType withModifyDate(Instant modifyDate)
+ {
+ this.modifyDate = modifyDate;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for name
+ *******************************************************************************/
+ public String getName()
+ {
+ return (this.name);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for name
+ *******************************************************************************/
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for name
+ *******************************************************************************/
+ public ProcessLockType withName(String name)
+ {
+ this.name = name;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for label
+ *******************************************************************************/
+ public String getLabel()
+ {
+ return (this.label);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for label
+ *******************************************************************************/
+ public void setLabel(String label)
+ {
+ this.label = label;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for label
+ *******************************************************************************/
+ public ProcessLockType withLabel(String label)
+ {
+ this.label = label;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for defaultExpirationSeconds
+ *******************************************************************************/
+ public Integer getDefaultExpirationSeconds()
+ {
+ return (this.defaultExpirationSeconds);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for defaultExpirationSeconds
+ *******************************************************************************/
+ public void setDefaultExpirationSeconds(Integer defaultExpirationSeconds)
+ {
+ this.defaultExpirationSeconds = defaultExpirationSeconds;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for defaultExpirationSeconds
+ *******************************************************************************/
+ public ProcessLockType withDefaultExpirationSeconds(Integer defaultExpirationSeconds)
+ {
+ this.defaultExpirationSeconds = defaultExpirationSeconds;
+ return (this);
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtils.java
new file mode 100644
index 00000000..3307eb5e
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtils.java
@@ -0,0 +1,291 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import java.io.Serializable;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.Optional;
+import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
+import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
+import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.SleepUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** Utility class for working with ProcessLock table - creating, checking-in,
+ ** and releasing process locks.
+ *******************************************************************************/
+public class ProcessLockUtils
+{
+ private static final QLogger LOG = QLogger.getLogger(ProcessLockUtils.class);
+
+ private static Memoization getProcessLockTypeByNameMemoization = new Memoization()
+ .withTimeout(Duration.ofHours(1))
+ .withMayStoreNullValues(false);
+
+ private static Memoization getProcessLockTypeByIdMemoization = new Memoization()
+ .withTimeout(Duration.ofHours(1))
+ .withMayStoreNullValues(false);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static ProcessLock create(String key, String typeName, String holder) throws UnableToObtainProcessLockException, QException
+ {
+ ProcessLockType lockType = getProcessLockTypeByName(typeName);
+ if(lockType == null)
+ {
+ throw (new QException("Unrecognized process lock type: " + typeName));
+ }
+
+ QSession qSession = QContext.getQSession();
+ holder = qSession.getUser().getIdReference() + "-" + qSession.getUuid() + "-" + holder;
+
+ Instant now = Instant.now();
+ ProcessLock processLock = new ProcessLock()
+ .withKey(key)
+ .withProcessLockTypeId(lockType.getId())
+ .withHolder(holder)
+ .withCheckInTimestamp(now);
+
+ Integer defaultExpirationSeconds = lockType.getDefaultExpirationSeconds();
+ if(defaultExpirationSeconds != null)
+ {
+ processLock.setExpiresAtTimestamp(now.plusSeconds(defaultExpirationSeconds));
+ }
+
+ QRecord insertOutputRecord = tryToInsert(processLock);
+
+ ////////////////////////////////////////////////////////////
+ // if inserting failed... see if we can get existing lock //
+ ////////////////////////////////////////////////////////////
+ StringBuilder existingLockDetails = new StringBuilder();
+ if(CollectionUtils.nullSafeHasContents(insertOutputRecord.getErrors()))
+ {
+ QRecord existingLockRecord = new GetAction().executeForRecord(new GetInput(ProcessLock.TABLE_NAME).withUniqueKey(Map.of("key", key, "processLockTypeId", lockType.getId())));
+ if(existingLockRecord != null)
+ {
+ existingLockDetails.append("Held by: ").append(existingLockRecord.getValueString("holder"));
+ Instant expiresAtTimestamp = existingLockRecord.getValueInstant("expiresAtTimestamp");
+ if(expiresAtTimestamp != null)
+ {
+ ZonedDateTime zonedExpiresAt = expiresAtTimestamp.atZone(ValueUtils.getSessionOrInstanceZoneId());
+ existingLockDetails.append("; Expires at: ").append(QValueFormatter.formatDateTimeWithZone(zonedExpiresAt));
+ }
+
+ if(expiresAtTimestamp != null && expiresAtTimestamp.isBefore(now))
+ {
+ /////////////////////////////////////////////////////////////////////////////////
+ // if existing lock has expired, then we can delete it and try to insert again //
+ /////////////////////////////////////////////////////////////////////////////////
+ Serializable id = existingLockRecord.getValue("id");
+ LOG.info("Existing lock has expired - deleting it and trying again.", logPair("id", id),
+ logPair("key", key), logPair("type", typeName), logPair("holder", holder), logPair("expiresAtTimestamp", expiresAtTimestamp));
+ new DeleteAction().execute(new DeleteInput(ProcessLock.TABLE_NAME).withPrimaryKey(id));
+ insertOutputRecord = tryToInsert(processLock);
+ }
+ }
+ else
+ {
+ /////////////////////////////////////////////////////////
+ // if existing lock doesn't exist, try to insert again //
+ /////////////////////////////////////////////////////////
+ insertOutputRecord = tryToInsert(processLock);
+ }
+ }
+
+ if(CollectionUtils.nullSafeHasContents(insertOutputRecord.getErrors()))
+ {
+ /////////////////////////////////////////////////////////////////////////////////
+ // if at this point, we have errors on the last attempted insert, then give up //
+ /////////////////////////////////////////////////////////////////////////////////
+ LOG.info("Errors in process lock record after attempted insert", logPair("errors", insertOutputRecord.getErrors()),
+ logPair("key", key), logPair("type", typeName), logPair("holder", holder));
+ throw (new UnableToObtainProcessLockException("A Process Lock already exists for key [" + key + "] of type [" + typeName + "], " + existingLockDetails));
+ }
+
+ LOG.info("Created process lock", logPair("id", processLock.getId()),
+ logPair("key", key), logPair("type", typeName), logPair("holder", holder), logPair("expiresAtTimestamp", processLock.getExpiresAtTimestamp()));
+ return new ProcessLock(insertOutputRecord);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static QRecord tryToInsert(ProcessLock processLock) throws QException
+ {
+ return new InsertAction().execute(new InsertInput(ProcessLock.TABLE_NAME).withRecordEntity(processLock)).getRecords().get(0);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static ProcessLock create(String key, String type, String holderId, Duration sleepBetweenTries, Duration maxWait) throws UnableToObtainProcessLockException, QException
+ {
+ Instant giveUpTime = Instant.now().plus(maxWait);
+
+ while(true)
+ {
+ try
+ {
+ ProcessLock processLock = create(key, type, holderId);
+ return (processLock);
+ }
+ catch(UnableToObtainProcessLockException e)
+ {
+ if(Instant.now().plus(sleepBetweenTries).isBefore(giveUpTime))
+ {
+ SleepUtils.sleep(sleepBetweenTries);
+ }
+ else
+ {
+ break;
+ }
+ }
+ }
+
+ throw (new UnableToObtainProcessLockException("Unable to obtain process lock for key [" + key + "] in type [" + type + "] after [" + maxWait + "]"));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static ProcessLock getById(Integer id) throws QException
+ {
+ QRecord existingLockRecord = new GetAction().executeForRecord(new GetInput(ProcessLock.TABLE_NAME).withPrimaryKey(id));
+ if(existingLockRecord != null)
+ {
+ return (new ProcessLock(existingLockRecord));
+ }
+ return null;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void checkIn(ProcessLock processLock) throws QException
+ {
+ ProcessLockType lockType = getProcessLockTypeById(processLock.getProcessLockTypeId());
+ if(lockType == null)
+ {
+ throw (new QException("Unrecognized process lock type id: " + processLock.getProcessLockTypeId()));
+ }
+
+ Instant now = Instant.now();
+ QRecord recordToUpdate = new QRecord()
+ .withValue("id", processLock.getId())
+ .withValue("checkInTimestamp", now);
+
+ Integer defaultExpirationSeconds = lockType.getDefaultExpirationSeconds();
+ if(defaultExpirationSeconds != null)
+ {
+ recordToUpdate.setValue("expiresAtTimestamp", now.plusSeconds(defaultExpirationSeconds));
+ }
+
+ new UpdateAction().execute(new UpdateInput(ProcessLock.TABLE_NAME).withRecord(recordToUpdate));
+ LOG.debug("Updated processLock checkInTimestamp", logPair("id", processLock.getId()), logPair("checkInTimestamp", now));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void release(ProcessLock processLock) throws QException
+ {
+ DeleteOutput deleteOutput = new DeleteAction().execute(new DeleteInput(ProcessLock.TABLE_NAME).withPrimaryKey(processLock.getId()));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static ProcessLockType getProcessLockTypeByName(String name)
+ {
+ Optional result = getProcessLockTypeByNameMemoization.getResult(name, n ->
+ {
+ QRecord qRecord = new GetAction().executeForRecord(new GetInput(ProcessLockType.TABLE_NAME).withUniqueKey(Map.of("name", name)));
+
+ if(qRecord != null)
+ {
+ return (new ProcessLockType(qRecord));
+ }
+
+ return (null);
+ });
+
+ return (result.orElse(null));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static ProcessLockType getProcessLockTypeById(Integer id)
+ {
+ Optional result = getProcessLockTypeByIdMemoization.getResult(id, i ->
+ {
+ QRecord qRecord = new GetAction().executeForRecord(new GetInput(ProcessLockType.TABLE_NAME).withPrimaryKey(id));
+
+ if(qRecord != null)
+ {
+ return (new ProcessLockType(qRecord));
+ }
+
+ return (null);
+ });
+
+ return (result.orElse(null));
+ }
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/UnableToObtainProcessLockException.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/UnableToObtainProcessLockException.java
new file mode 100644
index 00000000..a14f9f02
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/locks/UnableToObtainProcessLockException.java
@@ -0,0 +1,52 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
+
+
+/*******************************************************************************
+ ** Lock thrown by ProcessLockUtils when you can't get the lock.
+ *******************************************************************************/
+public class UnableToObtainProcessLockException extends QUserFacingException
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public UnableToObtainProcessLockException(String message)
+ {
+ super(message);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public UnableToObtainProcessLockException(String message, Throwable cause)
+ {
+ super(message, cause);
+ }
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtilsTest.java
new file mode 100644
index 00000000..b665fed0
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/locks/ProcessLockUtilsTest.java
@@ -0,0 +1,196 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.processes.locks;
+
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.SleepUtils;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+
+/*******************************************************************************
+ ** Unit test for ProcessLockUtils
+ *******************************************************************************/
+class ProcessLockUtilsTest extends BaseTest
+{
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void beforeEach() throws QException
+ {
+ QInstance qInstance = QContext.getQInstance();
+ MetaDataProducerMultiOutput metaData = new ProcessLockMetaDataProducer().produce(qInstance);
+
+ for(QTableMetaData table : metaData.getEach(QTableMetaData.class))
+ {
+ table.setBackendName(TestUtils.MEMORY_BACKEND_NAME);
+ }
+
+ metaData.addSelfToInstance(qInstance);
+
+ new InsertAction().execute(new InsertInput(ProcessLockType.TABLE_NAME).withRecordEntities(List.of(
+ new ProcessLockType()
+ .withName("typeA")
+ .withLabel("Type A"),
+ new ProcessLockType()
+ .withName("typeB")
+ .withLabel("Type B")
+ .withDefaultExpirationSeconds(1),
+ new ProcessLockType()
+ .withName("typeC")
+ .withLabel("Type C")
+ .withDefaultExpirationSeconds(10)
+ )));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ /////////////////////////////////////////
+ // make sure that we can create a lock //
+ /////////////////////////////////////////
+ ProcessLock processLock = ProcessLockUtils.create("1", "typeA", "me");
+ assertNotNull(processLock.getId());
+ assertNotNull(processLock.getCheckInTimestamp());
+ assertNull(processLock.getExpiresAtTimestamp());
+
+ /////////////////////////////////////////////////////////
+ // make sure we can't create a second for the same key //
+ /////////////////////////////////////////////////////////
+ assertThatThrownBy(() -> ProcessLockUtils.create("1", "typeA", "you"))
+ .isInstanceOf(UnableToObtainProcessLockException.class);
+
+ /////////////////////////////////////////////////////////
+ // make sure we can create another for a different key //
+ /////////////////////////////////////////////////////////
+ ProcessLockUtils.create("2", "typeA", "him");
+
+ /////////////////////////////////////////////////////////////////////
+ // make sure we can create another for a different type (same key) //
+ /////////////////////////////////////////////////////////////////////
+ ProcessLockUtils.create("1", "typeB", "her");
+
+ //////////////////////////////
+ // make sure we can release //
+ //////////////////////////////
+ ProcessLockUtils.release(processLock);
+
+ //////////////////////
+ // and then you can //
+ //////////////////////
+ processLock = ProcessLockUtils.create("1", "typeA", "you");
+ assertNotNull(processLock.getId());
+ assertThat(processLock.getHolder()).endsWith("you");
+
+ assertThatThrownBy(() -> ProcessLockUtils.create("1", "notAType", "you"))
+ .isInstanceOf(QException.class)
+ .hasMessageContaining("Unrecognized process lock type");
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testSucceedWaitingForExpiration() throws QException
+ {
+ ProcessLock processLock = ProcessLockUtils.create("1", "typeB", "me");
+ assertNotNull(processLock.getId());
+ assertNotNull(processLock.getCheckInTimestamp());
+ assertNotNull(processLock.getExpiresAtTimestamp());
+
+ /////////////////////////////////////////////////////////////////////////
+ // make sure someone else can, if they wait longer than the expiration //
+ /////////////////////////////////////////////////////////////////////////
+ processLock = ProcessLockUtils.create("1", "typeB", "you", Duration.of(1, ChronoUnit.SECONDS), Duration.of(3, ChronoUnit.SECONDS));
+ assertNotNull(processLock.getId());
+ assertThat(processLock.getHolder()).endsWith("you");
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testFailWaitingForExpiration() throws QException
+ {
+ ProcessLock processLock = ProcessLockUtils.create("1", "typeC", "me");
+ assertNotNull(processLock.getId());
+ assertNotNull(processLock.getCheckInTimestamp());
+ assertNotNull(processLock.getExpiresAtTimestamp());
+
+ //////////////////////////////////////////////////////////////////
+ // make sure someone else fails, if they don't wait long enough //
+ //////////////////////////////////////////////////////////////////
+ assertThatThrownBy(() -> ProcessLockUtils.create("1", "typeC", "you", Duration.of(1, ChronoUnit.SECONDS), Duration.of(3, ChronoUnit.SECONDS)))
+ .isInstanceOf(UnableToObtainProcessLockException.class);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testCheckInUpdatesExpiration() throws QException
+ {
+ ProcessLock processLock = ProcessLockUtils.create("1", "typeB", "me");
+ assertNotNull(processLock.getId());
+ Instant originalCheckIn = processLock.getCheckInTimestamp();
+ Instant originalExpiration = processLock.getExpiresAtTimestamp();
+
+ SleepUtils.sleep(5, TimeUnit.MILLISECONDS);
+ ProcessLockUtils.checkIn(processLock);
+
+ ProcessLock freshLock = ProcessLockUtils.getById(processLock.getId());
+ assertNotEquals(originalCheckIn, freshLock.getCheckInTimestamp());
+ assertNotEquals(originalExpiration, freshLock.getExpiresAtTimestamp());
+ }
+
+}
\ No newline at end of file