Fix contention aware patch in transaction (#4416)

* Fix contention aware patch in transaction

* Add changelog
This commit is contained in:
James Agnew 2023-01-08 17:51:34 -05:00 committed by GitHub
parent 02de3e49e3
commit 10615eb64c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 198 additions and 40 deletions

View File

@ -0,0 +1,6 @@
---
type: add
issue: 4416
title: "The JPA server now supports contention aware patch operations (i.e. using `If-Match`)
within a FHIR Transaction. Previously the `Bundle.entry.request.ifMatch` element was incorrectly
treated as a conditional URL and not a version tag."

View File

@ -5,6 +5,7 @@ import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
import com.google.common.base.Charsets;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
@ -17,6 +18,7 @@ import org.hl7.fhir.r4.model.Binary;
import org.hl7.fhir.r4.model.BooleanType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.CodeType;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Media;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.OperationOutcome;
@ -29,6 +31,7 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
@ -74,6 +77,71 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
assertEquals(1, resultingResource.getIdentifier().size());
}
@Test
public void testFhirPatch_ContentionAware_Match() {
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
Parameters patch = new Parameters();
Parameters.ParametersParameterComponent op = patch.addParameter().setName("operation");
op.addPart().setName("type").setValue(new CodeType("replace"));
op.addPart().setName("path").setValue(new CodeType("Patient.active"));
op.addPart().setName("value").setValue(new BooleanType(false));
MethodOutcome outcome = myClient
.patch()
.withFhirPatch(patch)
.withId(pid1)
.withAdditionalHeader(Constants.HEADER_IF_MATCH, "W/\"1\"")
.execute();
assertEquals("2", outcome.getId().getVersionIdPart());
Patient newPt = myClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("2", newPt.getIdElement().getVersionIdPart());
assertEquals(false, newPt.getActive());
}
@Test
public void testFhirPatch_ContentionAware_NoMatch() {
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
patient.setBirthDate(new Date());
myPatientDao.update(patient, mySrd);
}
Parameters patch = new Parameters();
Parameters.ParametersParameterComponent op = patch.addParameter().setName("operation");
op.addPart().setName("type").setValue(new CodeType("replace"));
op.addPart().setName("path").setValue(new CodeType("Patient.active"));
op.addPart().setName("value").setValue(new BooleanType(false));
try {
myClient
.patch()
.withFhirPatch(patch)
.withId(pid1)
.withAdditionalHeader(Constants.HEADER_IF_MATCH, "W/\"1\"")
.execute();
fail();
} catch (ResourceVersionConflictException e) {
// good
}
Patient newPt = myClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("2", newPt.getIdElement().getVersionIdPart());
assertEquals(true, newPt.getActive());
}
@Test
public void testFhirPatch_Transaction() throws Exception {
String methodName = "testFhirPatch_Transaction";
@ -115,6 +183,88 @@ public class PatchProviderR4Test extends BaseResourceProviderR4Test {
assertEquals(false, newPt.getActive());
}
@Test
public void testFhirPatch_TransactionContentionAware_Match() {
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
}
Parameters patch = new Parameters();
Parameters.ParametersParameterComponent op = patch.addParameter().setName("operation");
op.addPart().setName("type").setValue(new CodeType("replace"));
op.addPart().setName("path").setValue(new CodeType("Patient.active"));
op.addPart().setName("value").setValue(new BooleanType(false));
Bundle input = new Bundle();
input.setType(Bundle.BundleType.TRANSACTION);
input.addEntry()
.setFullUrl(pid1.getValue())
.setResource(patch)
.getRequest()
.setUrl(pid1.getValue())
.setIfMatch("W/\"1\"")
.setMethod(Bundle.HTTPVerb.PATCH);
Bundle outcome = myClient
.transaction()
.withBundle(input)
.execute();
ourLog.info(myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(outcome));
assertEquals("2", new IdType(outcome.getEntry().get(0).getResponse().getLocation()).getVersionIdPart());
Patient newPt = myClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("2", newPt.getIdElement().getVersionIdPart());
assertEquals(false, newPt.getActive());
}
@Test
public void testFhirPatch_TransactionContentionAware_NoMatch() {
IIdType pid1;
{
Patient patient = new Patient();
patient.setActive(true);
pid1 = myPatientDao.create(patient, mySrd).getId().toUnqualifiedVersionless();
patient.setBirthDate(new Date());
myPatientDao.update(patient, mySrd);
}
Parameters patch = new Parameters();
Parameters.ParametersParameterComponent op = patch.addParameter().setName("operation");
op.addPart().setName("type").setValue(new CodeType("replace"));
op.addPart().setName("path").setValue(new CodeType("Patient.active"));
op.addPart().setName("value").setValue(new BooleanType(false));
try {
Bundle input = new Bundle();
input.setType(Bundle.BundleType.TRANSACTION);
input.addEntry()
.setFullUrl(pid1.getValue())
.setResource(patch)
.getRequest()
.setUrl(pid1.getValue())
.setIfMatch("W/\"1\"")
.setMethod(Bundle.HTTPVerb.PATCH);
myClient
.transaction()
.withBundle(input)
.execute();
fail();
} catch (ResourceVersionConflictException e) {
// good
}
Patient newPt = myClient.read().resource(Patient.class).withId(pid1.getIdPart()).execute();
assertEquals("2", newPt.getIdElement().getVersionIdPart());
assertEquals(true, newPt.getActive());
}
@Test
public void testFhirPatch_TransactionWithSearchParameter() throws Exception {
String methodName = "testFhirPatch_Transaction";

View File

@ -33,7 +33,6 @@ import org.hl7.fhir.instance.model.api.IIdType;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Update;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.RequestTypeEnum;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
@ -41,6 +40,7 @@ import ca.uhn.fhir.rest.param.ParameterUtil;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public class UpdateMethodBinding extends BaseOutcomeReturningMethodBindingWithResourceParam {
@ -60,15 +60,19 @@ public class UpdateMethodBinding extends BaseOutcomeReturningMethodBindingWithRe
super.addParametersForServerRequest(theRequest, theParams);
}
public static IIdType applyETagAsVersion(RequestDetails theRequest, IIdType id) {
public static IIdType applyETagAsVersion(RequestDetails theRequest, IIdType theId) {
String ifMatchValue = theRequest.getHeader(Constants.HEADER_IF_MATCH);
if (isNotBlank(ifMatchValue)) {
ifMatchValue = ParameterUtil.parseETagValue(ifMatchValue);
if (id != null && id.hasVersionIdPart() == false) {
id = id.withVersion(ifMatchValue);
return applyETagAsVersion(ifMatchValue, theId);
}
public static IIdType applyETagAsVersion(String theIfMatchValue, IIdType theId) {
if (isNotBlank(theIfMatchValue)) {
theIfMatchValue = ParameterUtil.parseETagValue(theIfMatchValue);
if (theId != null && theId.hasVersionIdPart() == false) {
theId = theId.withVersion(theIfMatchValue);
}
}
return id;
return theId;
}
@Override

View File

@ -71,6 +71,7 @@ import ca.uhn.fhir.rest.server.exceptions.PayloadTooLargeException;
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
import ca.uhn.fhir.rest.server.method.BaseMethodBinding;
import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding;
import ca.uhn.fhir.rest.server.method.UpdateMethodBinding;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails;
import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
@ -108,20 +109,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Nonnull;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@ -139,8 +127,8 @@ public abstract class BaseTransactionProcessor {
public static final String URN_PREFIX = "urn:";
public static final String URN_PREFIX_ESCAPED = UrlUtil.escapeUrlParam(URN_PREFIX);
public static final Pattern UNQUALIFIED_MATCH_URL_START = Pattern.compile("^[a-zA-Z0-9_]+=");
private static final Logger ourLog = LoggerFactory.getLogger(BaseTransactionProcessor.class);
public static final Pattern INVALID_PLACEHOLDER_PATTERN = Pattern.compile("[a-zA-Z]+:.*");
private static final Logger ourLog = LoggerFactory.getLogger(BaseTransactionProcessor.class);
private BaseStorageDao myDao;
@Autowired
private PlatformTransactionManager myTxManager;
@ -192,7 +180,7 @@ public abstract class BaseTransactionProcessor {
private TaskExecutor getTaskExecutor() {
if (myExecutor == null) {
myExecutor = myThreadPoolFactory.newThreadPool(myDaoConfig.getBundleBatchPoolSize(), myDaoConfig.getBundleBatchMaxPoolSize(), "bundle-batch-");
myExecutor = myThreadPoolFactory.newThreadPool(myDaoConfig.getBundleBatchPoolSize(), myDaoConfig.getBundleBatchMaxPoolSize(), "bundle-batch-");
}
return myExecutor;
}
@ -271,7 +259,7 @@ public abstract class BaseTransactionProcessor {
populateIdToPersistedOutcomeMap(idToPersistedOutcome, newId, outcome);
if(shouldSwapBinaryToActualResource(theRes, theResourceType, nextResourceId)) {
if (shouldSwapBinaryToActualResource(theRes, theResourceType, nextResourceId)) {
theRes = idToPersistedOutcome.get(newId).getResource();
}
@ -1097,7 +1085,16 @@ public abstract class BaseTransactionProcessor {
IFhirResourceDao<? extends IBaseResource> dao = toDao(parts, verb, url);
IIdType patchId = myContext.getVersion().newIdType().setValue(parts.getResourceId());
String conditionalUrl = isNull(patchId.getIdPart()) ? url : matchUrl;
String conditionalUrl;
if (isNull(patchId.getIdPart())) {
conditionalUrl = url;
} else {
conditionalUrl = matchUrl;
String ifMatch = myVersionAdapter.getEntryRequestIfMatch(nextReqEntry);
if (isNotBlank(ifMatch)) {
patchId = UpdateMethodBinding.applyETagAsVersion(ifMatch, patchId);
}
}
DaoMethodOutcome outcome = dao.patchInTransaction(patchId, conditionalUrl, false, patchType, patchBody, patchBodyParameters, theRequest, theTransactionDetails);
setConditionalUrlToBeValidatedLater(conditionalUrlToIdMap, matchUrl, outcome.getId());
@ -1627,7 +1624,7 @@ public abstract class BaseTransactionProcessor {
* Extracts the transaction url from the entry and verifies it's:
* * not null or bloack
* * is a relative url matching the resourceType it is about
*
* <p>
* Returns the transaction url (or throws an InvalidRequestException if url is not valid)
*/
private String extractAndVerifyTransactionUrlForEntry(IBase theEntry, String theVerb) {
@ -1643,7 +1640,7 @@ public abstract class BaseTransactionProcessor {
/**
* Returns true if the provided url is a valid entry request.url.
*
* <p>
* This means:
* a) not an absolute url (does not start with http/https)
* b) starts with either a ResourceType or /ResourceType
@ -1705,20 +1702,21 @@ public abstract class BaseTransactionProcessor {
private String toMatchUrl(IBase theEntry) {
String verb = myVersionAdapter.getEntryRequestVerb(myContext, theEntry);
if (verb.equals("POST")) {
return myVersionAdapter.getEntryIfNoneExist(theEntry);
switch (defaultString(verb)) {
case "POST":
return myVersionAdapter.getEntryIfNoneExist(theEntry);
case "PUT":
case "DELETE":
case "PATCH":
String url = extractTransactionUrlOrThrowException(theEntry, verb);
UrlUtil.UrlParts parts = UrlUtil.parseUrl(url);
if (isBlank(parts.getResourceId())) {
return parts.getResourceType() + '?' + parts.getParams();
}
return null;
default:
return null;
}
if (verb.equals("PATCH")) {
return myVersionAdapter.getEntryRequestIfMatch(theEntry);
}
if (verb.equals("PUT") || verb.equals("DELETE")) {
String url = extractTransactionUrlOrThrowException(theEntry, verb);
UrlUtil.UrlParts parts = UrlUtil.parseUrl(url);
if (isBlank(parts.getResourceId())) {
return parts.getResourceType() + '?' + parts.getParams();
}
}
return null;
}
/**