TSK-1275: Support select and claim in one API call

This commit is contained in:
Joerg Heffner 2020-06-08 11:15:45 +02:00 committed by gitgoodjhe
parent 135d78ff54
commit 1dfe54c0b6
13 changed files with 339 additions and 5 deletions

View File

@ -325,6 +325,21 @@ public interface TaskService {
void forceDeleteTask(String taskId)
throws TaskNotFoundException, InvalidStateException, NotAuthorizedException;
/**
* Selects and claims the first task which is returned by the task query.
*
* @param taskQuery the task query.
* @return the task that got selected and claimed
* @throws TaskNotFoundException if the task with taskId was not found
* @throws InvalidStateException if the state of the task with taskId is not READY
* @throws InvalidOwnerException if the task with taskId is claimed by someone else
* @throws NotAuthorizedException if the current user has no read permission for the
* workbasket the task is in
*/
Task selectAndClaim(TaskQuery taskQuery)
throws TaskNotFoundException, NotAuthorizedException, InvalidStateException,
InvalidOwnerException;
/**
* Deletes a list of tasks.
*

View File

@ -149,6 +149,7 @@ public class TaskQueryImpl implements TaskQuery {
private List<String> orderColumns;
private WildcardSearchField[] wildcardSearchFieldIn;
private String wildcardSearchValueLike;
private boolean selectAndClaim;
private boolean useDistinctKeyword = false;
private boolean joinWithAttachments = false;
@ -449,6 +450,11 @@ public class TaskQueryImpl implements TaskQuery {
return this;
}
public TaskQuery selectAndClaimEquals(boolean selectAndClaim) {
this.selectAndClaim = selectAndClaim;
return this;
}
@Override
public TaskQuery parentBusinessProcessIdIn(String... parentBusinessProcessIds) {
this.parentBusinessProcessIdIn = parentBusinessProcessIds;
@ -1105,7 +1111,11 @@ public class TaskQueryImpl implements TaskQuery {
}
public String getLinkToMapperScript() {
return DB.DB2.dbProductId.equals(getDatabaseId()) ? LINK_TO_MAPPER_DB2 : LINK_TO_MAPPER;
if (DB.DB2.dbProductId.equals(getDatabaseId()) && !selectAndClaim) {
return LINK_TO_MAPPER_DB2;
} else {
return LINK_TO_MAPPER;
}
}
public String getLinkToCounterTaskScript() {
@ -1206,6 +1216,10 @@ public class TaskQueryImpl implements TaskQuery {
return isTransferred;
}
public boolean getIsSelectAndClaim() {
return selectAndClaim;
}
public String[] getPorCompanyIn() {
return porCompanyIn;
}
@ -1931,6 +1945,8 @@ public class TaskQueryImpl implements TaskQuery {
+ wildcardSearchFieldIn
+ ", wildcardSearchValueLike="
+ wildcardSearchValueLike
+ ", selectAndClaim="
+ selectAndClaim
+ "]";
}
}

View File

@ -134,8 +134,11 @@ public interface TaskQueryMapper {
+ "<if test='attachmentReferenceLike != null'>AND (<foreach item='item' collection='attachmentReferenceLike' separator=' OR '>UPPER(a.REF_VALUE) LIKE #{item}</foreach>)</if> "
+ "<if test='attachmentReceivedIn !=null'> AND ( <foreach item='item' collection='attachmentReceivedIn' separator=' OR ' > ( <if test='item.begin!=null'> a.RECEIVED &gt;= #{item.begin} </if> <if test='item.begin!=null and item.end!=null'> AND </if><if test='item.end!=null'> a.RECEIVED &lt;=#{item.end} </if>)</foreach>)</if> "
+ "<if test='wildcardSearchValueLike != null and wildcardSearchFieldIn != null'>AND (<foreach item='item' collection='wildcardSearchFieldIn' separator=' OR '>t.${item} LIKE #{wildcardSearchValueLike}</foreach>)</if> "
+ "<if test='selectAndClaim == true'> AND t.STATE = 'READY' </if>"
+ "</where>"
+ "<if test='!orderBy.isEmpty()'>ORDER BY <foreach item='item' collection='orderBy' separator=',' >${item}</foreach></if> "
+ "<if test='selectAndClaim == true'> FETCH FIRST ROW ONLY FOR UPDATE </if>"
+ "<if test=\"_databaseId == 'db2'\">WITH RS USE AND KEEP UPDATE LOCKS </if> "
+ "</script>")
@Results(
value = {
@ -334,6 +337,7 @@ public interface TaskQueryMapper {
+ "</if>"
+ "<if test=\"addAttachmentClassificationNameToSelectClauseForOrdering\">"
+ ", ACNAME "
+ "</if>"
+ ", FLAG ) "
+ "AS "
@ -376,7 +380,8 @@ public interface TaskQueryMapper {
+ "${item}"
+ "</foreach>"
+ "</if> "
+ "with UR "
+ "<if test='selectAndClaim == true'>FETCH FIRST ROW ONLY FOR UPDATE WITH RS USE AND KEEP UPDATE LOCKS</if>"
+ "<if test='selectAndClaim == false'> with UR</if>"
+ "</script>")
@Results(
value = {

View File

@ -477,6 +477,37 @@ public class TaskServiceImpl implements TaskService {
deleteTask(taskId, true);
}
@Override
public Task selectAndClaim(TaskQuery taskQuery)
throws TaskNotFoundException, NotAuthorizedException, InvalidStateException,
InvalidOwnerException {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("entry to selectAndClaim(taskQuery = {})", taskQuery);
}
try {
taskanaEngine.openConnection();
((TaskQueryImpl) taskQuery).selectAndClaimEquals(true);
TaskSummary taskSummary = taskQuery.single();
if (taskSummary == null) {
throw new SystemException(
"No tasks matched the specified filter and sorting options,"
+ " task query returned nothing!");
}
return claim(taskSummary.getId());
} finally {
LOGGER.debug("exit from selectAndClaim()");
taskanaEngine.returnConnection();
}
}
@Override
public BulkOperationResults<String, TaskanaException> deleteTasks(List<String> taskIds)
throws InvalidArgumentException, NotAuthorizedException {

View File

@ -44,6 +44,7 @@ public abstract class AbstractAccTest {
}
public static void resetDb(boolean dropTables) throws SQLException {
DataSource dataSource = TaskanaEngineTestConfiguration.getDataSource();
String schemaName = TaskanaEngineTestConfiguration.getSchemaName();
SampleDataGenerator sampleDataGenerator = new SampleDataGenerator(dataSource, schemaName);

View File

@ -0,0 +1,108 @@
package acceptance.task;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import acceptance.AbstractAccTest;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.security.auth.Subject;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import pro.taskana.common.api.BaseQuery.SortDirection;
import pro.taskana.common.api.exceptions.SystemException;
import pro.taskana.common.internal.security.JaasExtension;
import pro.taskana.common.internal.security.UserPrincipal;
import pro.taskana.common.internal.security.WithAccessId;
import pro.taskana.common.internal.util.CheckedConsumer;
import pro.taskana.task.api.TaskQuery;
import pro.taskana.task.api.TaskService;
import pro.taskana.task.api.models.Task;
@ExtendWith(JaasExtension.class)
class SelectAndClaimTaskAccTest extends AbstractAccTest {
@Test
void should_claimDifferentTasks_For_ConcurrentSelectAndClaimCalls() throws Exception {
List<Task> selectedAndClaimedTasks = Collections.synchronizedList(new ArrayList<>());
List<String> accessIds =
Collections.synchronizedList(
Stream.of("admin", "teamlead-1", "teamlead-2", "taskadmin")
.collect(Collectors.toList()));
Runnable test = getRunnableTest(selectedAndClaimedTasks, accessIds);
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(test);
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
assertThat(selectedAndClaimedTasks.stream().map(Task::getId))
.containsExactlyInAnyOrder(
"TKI:000000000000000000000000000000000003",
"TKI:000000000000000000000000000000000004",
"TKI:000000000000000000000000000000000005",
"TKI:000000000000000000000000000000000006");
assertThat(selectedAndClaimedTasks.stream().map(Task::getOwner))
.containsExactlyInAnyOrder("admin", "taskadmin", "teamlead-1", "teamlead-2");
}
@Test
@WithAccessId(user = "admin")
void should_ThrowException_When_TryingToSelectAndClaimNonExistingTask() throws Exception {
TaskQuery query = taskanaEngine.getTaskService().createTaskQuery();
query.idIn("notexisting");
ThrowingCallable call =
() -> {
taskanaEngine.getTaskService().selectAndClaim(query);
};
assertThatThrownBy(call)
.isInstanceOf(SystemException.class)
.hasMessageContaining(
"No tasks matched the specified filter and sorting options, "
+ "task query returned nothing!");
}
private Runnable getRunnableTest(List<Task> selectedAndClaimedTasks, List<String> accessIds) {
Runnable test =
() -> {
Subject subject = new Subject();
subject.getPrincipals().add(new UserPrincipal(accessIds.remove(0)));
Consumer<TaskService> consumer =
CheckedConsumer.wrap(
taskService -> {
Task task = taskService.selectAndClaim(getTaskQuery());
selectedAndClaimedTasks.add(task);
});
PrivilegedAction<Void> action =
() -> {
consumer.accept(taskanaEngine.getTaskService());
return null;
};
Subject.doAs(subject, action);
};
return test;
}
private TaskQuery getTaskQuery() {
return taskanaEngine.getTaskService().createTaskQuery().orderByTaskId(SortDirection.ASCENDING);
}
}

View File

@ -0,0 +1,79 @@
logging.level.pro.taskana=INFO
logging.level.org.springframework.security=INFO
server.servlet.context-path=/taskana
######## Taskana DB #######
######## h2 configuration ########
########spring.datasource.url=jdbc:h2:mem:taskana;IGNORECASE=TRUE;LOCK_MODE=0
########spring.datasource.driverClassName=org.h2.Driver
########spring.datasource.username=sa
########spring.datasource.password=sa
taskana.schemaName=TASKANA
######## h2 console configuration ########
########spring.h2.console.enabled=true
########spring.h2.console.path=/h2-console
######## db2 configuration ########
spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver
spring.datasource.url=jdbc:db2://localhost:50000/tskdb
spring.datasource.username=db2inst1
spring.datasource.password=db2inst1-pwd
######## Postgres configuration ########
########spring.datasource.url=jdbc:postgresql://localhost/taskana
########spring.datasource.driverClassName=org.postgresql.Driver
########spring.datasource.username=postgres
########spring.datasource.password=1234
########spring.jpa.generate-ddl=true
########spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
####### property that control rest api security deploy use true for no security.
devMode=false
####### property that control if the database is cleaned and sample data is generated
generateSampleData=true
####### JobScheduler cron expression that specifies when the JobSchedler runs
taskana.jobscheduler.async.cron=0 * * * * *
####### cache static resources properties
spring.resources.cache.cachecontrol.cache-private=true
####### for upload of big workbasket- or classification-files
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
spring.main.allow-bean-definition-overriding=true
server.tomcat.max-http-post-size=-1
server.tomcat.max-save-post-size=-1
server.tomcat.max-swallow-size=-1
####### tomcat is not detecting the x-forward headers from bluemix as a trustworthy proxy
server.tomcat.internal-proxies=.*
server.use-forward-headers=true
####### Properties for AccessIdController to connect to LDAP
taskana.ldap.serverUrl=ldap://localhost:10389
taskana.ldap.bindDn=uid=admin
taskana.ldap.bindPassword=secret
taskana.ldap.baseDn=ou=Test,O=TASKANA
taskana.ldap.userSearchBase=cn=users
taskana.ldap.userSearchFilterName=objectclass
taskana.ldap.userSearchFilterValue=person
taskana.ldap.userFirstnameAttribute=givenName
taskana.ldap.userLastnameAttribute=sn
taskana.ldap.userIdAttribute=uid
taskana.ldap.groupSearchBase=cn=groups
taskana.ldap.groupSearchFilterName=objectclass
taskana.ldap.groupSearchFilterValue=groupOfUniqueNames
taskana.ldap.groupNameAttribute=cn
taskana.ldap.minSearchForLength=3
taskana.ldap.maxNumberOfReturnedAccessIds=50
taskana.ldap.groupsOfUser=memberUid
# Embedded Spring LDAP server
spring.ldap.embedded.base-dn= OU=Test,O=TASKANA
spring.ldap.embedded.credential.username= uid=admin
spring.ldap.embedded.credential.password= secret
spring.ldap.embedded.ldif=classpath:taskana-example.ldif
spring.ldap.embedded.port= 10389
spring.ldap.embedded.validation.enabled=false

View File

@ -7,11 +7,19 @@ server.servlet.context-path=/taskana
########spring.datasource.driverClassName=org.h2.Driver
########spring.datasource.username=sa
########spring.datasource.password=sa
taskana.schemaName=taskana
taskana.schemaName=TASKANA
######## h2 console configuration ########
########spring.h2.console.enabled=true
########spring.h2.console.path=/h2-console
######## db2 configuration ########
########spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver
########spring.datasource.url=jdbc:db2://localhost:50000/tskdb
########spring.datasource.username=db2inst1
########spring.datasource.password=db2inst1-pwd
########taskana.schemaName=TASKANA
######## Postgres configuration ########
spring.datasource.url=jdbc:postgresql://localhost/postgres
spring.datasource.driverClassName=org.postgresql.Driver

View File

@ -15,6 +15,12 @@ taskana.schemaName=TASKANA
########spring.h2.console.enabled=true
########spring.h2.console.path=/h2-console
######## db2 configuration ########
########spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver
########spring.datasource.url=jdbc:db2://localhost:50000/tskdb
########spring.datasource.username=db2inst1
########spring.datasource.password=db2inst1-pwd
######## Postgres configuration ########
########spring.datasource.url=jdbc:postgresql://localhost/taskana
########spring.datasource.driverClassName=org.postgresql.Driver

View File

@ -34,6 +34,7 @@ public final class Mapping {
public static final String URL_TASK_COMMENTS = URL_TASKS + "/comments";
public static final String URL_TASK_COMMENT = URL_TASK_COMMENTS + "/{taskCommentId}";
public static final String URL_TASKS_ID_CLAIM = URL_TASKS_ID + "/claim";
public static final String URL_TASKS_ID_SELECT_AND_CLAIM = URL_TASKS + "/select-and-claim";
public static final String URL_TASKS_ID_COMPLETE = URL_TASKS_ID + "/complete";
public static final String URL_TASKS_ID_TRANSFER_WORKBASKETID =
URL_TASKS_ID + "/transfer/{workbasketId}";

View File

@ -85,6 +85,7 @@ public class TaskController extends AbstractPagingController {
private static final String EXTERNAL_ID = "external-id";
private static final String WILDCARD_SEARCH_VALUE = "wildcard-search-value";
private static final String WILDCARD_SEARCH_FIELDS = "wildcard-search-fields";
private static final String CUSTOM = "custom";
private static final String SORT_BY = "sort-by";
private static final String SORT_DIRECTION = "order";
@ -166,6 +167,30 @@ public class TaskController extends AbstractPagingController {
return result;
}
@PostMapping(path = Mapping.URL_TASKS_ID_SELECT_AND_CLAIM)
@Transactional(rollbackFor = Exception.class)
public ResponseEntity<TaskRepresentationModel> selectAndClaimTask(
@RequestParam MultiValueMap<String, String> params)
throws TaskNotFoundException, InvalidStateException, InvalidOwnerException,
NotAuthorizedException, InvalidArgumentException {
LOGGER.debug("Entry to selectAndClaimTask");
TaskQuery query = taskService.createTaskQuery();
query = applyFilterParams(query, params);
query = applySortingParams(query, params);
Task selectedAndClaimedTask = taskService.selectAndClaim(query);
ResponseEntity<TaskRepresentationModel> result =
ResponseEntity.ok(taskRepresentationModelAssembler.toModel(selectedAndClaimedTask));
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Exit from selectAndClaimTask(), returning {}", result);
}
return result;
}
@DeleteMapping(path = Mapping.URL_TASKS_ID_CLAIM)
@Transactional(rollbackFor = Exception.class)
public ResponseEntity<TaskRepresentationModel> cancelClaimTask(@PathVariable String taskId)
@ -414,8 +439,15 @@ public class TaskController extends AbstractPagingController {
params.remove(EXTERNAL_ID);
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Exit from applyFilterParams(), returning {}", taskQuery);
for (int i = 1; i < 17; i++) {
if (params.containsKey(CUSTOM + i)) {
String[] customValues = extractCommaSeparatedFields(params.get(CUSTOM + i));
taskQuery.customAttributeIn(String.valueOf(i), customValues);
if (LOGGER.isDebugEnabled()) {
params.remove(CUSTOM + i);
LOGGER.debug("Exit from applyFilterParams(), returning {}", taskQuery);
}
}
}
return taskQuery;

View File

@ -539,6 +539,20 @@ class TaskControllerRestDocumentation extends BaseRestDocumentation {
responseFields(taskFieldDescriptors)));
}
@Test
void selectAndClaimTaskDocTest() throws Exception {
this.mockMvc
.perform(
RestDocumentationRequestBuilders.post(
restHelper.toUrl(Mapping.URL_TASKS_ID_SELECT_AND_CLAIM) + "?custom14=abc")
.accept("application/hal+json")
.header("Authorization", ADMIN_CREDENTIALS))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(
MockMvcRestDocumentation.document(
"SelectAndClaimTaskDocTest", responseFields(taskFieldDescriptors)));
}
@Test
void createAndDeleteTaskDocTest() throws Exception {

View File

@ -230,6 +230,24 @@ include::{snippets}/CancelClaimTaskDocTest/http-response.adoc[]
The response-body is essentially the same as for getting a single task. +
Therefore for the response fields you can refer to the <<task, single task>>.
=== Select and Claim a task
A `POST` request is used to select and claim a task
==== Example Request
Note the empty request-body in the example.
include::{snippets}/SelectAndClaimTaskDocTest/http-request.adoc[]
==== Example Response
include::{snippets}/SelectAndClaimTaskDocTest/http-response.adoc[]
==== Response Structure
The response-body is essentially the same as for getting a single task. +
Therefore for the response fields you can refer to the <<task, single task>>.
=== Complete a task