Skip to content

Commit e359836

Browse files
committed
MID-11094 Fix correlation case closing on resolution error
(cherry picked from commit d35f7ff) (cherry picked from commit 8dfa4dc)
1 parent f8d7c71 commit e359836

File tree

8 files changed

+170
-21
lines changed

8 files changed

+170
-21
lines changed

model/cases-api/src/main/java/com/evolveum/midpoint/cases/api/extensions/EngineExtension.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,24 @@ public interface EngineExtension {
2929
@NotNull Collection<String> getArchetypeOids();
3030

3131
/**
32-
* Called to finish case closing procedure. E.g. for approvals we may submit execution task here.
32+
* Called before the case-closing state is persisted.
3333
*
34-
* When called, the case should be in `closing` state. This may happen e.g. when approval execution task is submitted.
35-
* After return, the case may be still in this state, or it may be `closed`.
34+
* Implementations may execute retry-safe business logic required before the case
35+
* may proceed to closing. If this method fails, the case remains open.
36+
*
37+
* Logic executed here must be safe under case-engine retries, e.g. in-memory,
38+
* idempotent, or repository-transactional and repeatable.
39+
*/
40+
default void prepareCaseClosing(@NotNull CaseEngineOperation operation, @NotNull OperationResult result)
41+
throws SchemaException, ObjectNotFoundException,
42+
ExpressionEvaluationException, ConfigurationException, CommunicationException, SecurityViolationException {
43+
}
44+
45+
/**
46+
* Called after the case-closing state has been successfully persisted.
47+
*
48+
* This hook is intended for post-commit finalization. Failures here do not mean
49+
* that the case should remain open.
3650
*/
3751
void finishCaseClosing(@NotNull CaseEngineOperation operation, @NotNull OperationResult result)
3852
throws SchemaException, ObjectAlreadyExistsException, ObjectNotFoundException,

model/cases-impl/src/main/java/com/evolveum/midpoint/cases/impl/engine/CaseEngineOperationImpl.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,20 @@ private void commit(OperationResult parentResult)
122122
.setMinor()
123123
.build();
124124
try {
125+
boolean caseIsClosing = CaseState.of(currentCase).isClosing();
126+
127+
if (caseIsClosing) {
128+
// Run retry-safe pre-close logic before persisting the case as closing.
129+
engineExtension.prepareCaseClosing(this, result);
130+
}
125131

126132
if (getCaseOid() == null) {
127133
addCaseToRepo(result);
128134
} else {
129135
modifyCaseInRepo(result); // Throws PreconditionViolationException if there's a race condition
130136
}
131137

132-
if (CaseState.of(currentCase).isClosing()) {
138+
if (caseIsClosing) {
133139
closeTheCase(result);
134140
}
135141

@@ -178,7 +184,7 @@ private void closeTheCase(OperationResult result)
178184
throws SchemaException, ObjectAlreadyExistsException, ObjectNotFoundException, ExpressionEvaluationException,
179185
ConfigurationException, CommunicationException, SecurityViolationException {
180186

181-
// Invoking specific postprocessing of the case, like submitting a task that executes approved deltas.
187+
// Invoking post-commit finalization specific to the case type.
182188
engineExtension.finishCaseClosing(this, result);
183189

184190
// Note that the case may still be in "closing" state. E.g. an approval case is still closing here,

model/cases-impl/src/main/java/com/evolveum/midpoint/cases/impl/engine/extension/CorrelationCaseEngineExtension.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ protected SimpleCaseSchemaType getCaseSchema(@NotNull CaseEngineOperation operat
5151
return context != null ? context.getSchema() : null;
5252
}
5353

54+
@Override
55+
public void prepareCaseClosing(
56+
@NotNull CaseEngineOperation operation,
57+
@NotNull OperationResult result)
58+
throws SchemaException, ObjectNotFoundException, ExpressionEvaluationException,
59+
CommunicationException, SecurityViolationException, ConfigurationException {
60+
correlationService.prepareCorrelationCaseClosing(
61+
operation.getCurrentCase(),
62+
operation.getTask(),
63+
result);
64+
}
65+
5466
@Override
5567
public void finishCaseClosing(
5668
@NotNull CaseEngineOperation operation,

model/model-api/src/main/java/com/evolveum/midpoint/model/api/correlation/CorrelationService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ void completeCorrelationCase(
8181
throws SchemaException, ExpressionEvaluationException, CommunicationException, SecurityViolationException,
8282
ConfigurationException, ObjectNotFoundException;
8383

84+
/**
85+
* Executes retry-safe business logic required before a correlation case may be
86+
* persisted as closing. If this method fails, the case remains open.
87+
*/
88+
default void prepareCorrelationCaseClosing(
89+
@NotNull CaseType currentCase,
90+
@NotNull Task task,
91+
@NotNull OperationResult result)
92+
throws SchemaException, ExpressionEvaluationException, CommunicationException, SecurityViolationException,
93+
ConfigurationException, ObjectNotFoundException {
94+
}
95+
8496
/**
8597
* Instantiates a correlator
8698
*/

model/model-impl/src/main/java/com/evolveum/midpoint/model/impl/correlation/CorrelationCaseManager.java

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
public class CorrelationCaseManager {
6464

6565
private static final Trace LOGGER = TraceManager.getTrace(CorrelationCaseManager.class);
66+
private static final String OP_APPLY_CORRELATION_OUTCOME =
67+
CorrelationCaseManager.class.getName() + ".importSelectedOutcome";
6668

6769
@Autowired @Qualifier("cacheRepositoryService") private RepositoryService repositoryService;
6870
@Autowired private ModelService modelService;
@@ -122,7 +124,7 @@ private void createCase(
122124
.schema(createCaseSchema(resource.getBusiness())))
123125
.state(SchemaConstants.CASE_STATE_CREATED)
124126
.metadata(new MetadataType()
125-
.createTimestamp(now));
127+
.createTimestamp(now));
126128
try {
127129
repositoryService.addObject(newCase.asPrismObject(), null, result);
128130
} catch (ObjectAlreadyExistsException e) {
@@ -290,33 +292,79 @@ void completeCorrelationCase(
290292
return;
291293
}
292294

293-
recordCaseCompletionInShadow(aCase, task, result);
295+
recordCorrelationCompletionMetadataInShadow(aCase, task, result);
294296
caseCloser.closeCase(result);
297+
298+
}
299+
300+
/**
301+
* Executes retry-safe correlation completion logic before the case is persisted as closing.
302+
* Throws on failure to keep the case open.
303+
*/
304+
void prepareCorrelationCaseClosing(
305+
@NotNull CaseType aCase,
306+
@NotNull Task task,
307+
@NotNull OperationResult result)
308+
throws SchemaException, ExpressionEvaluationException, CommunicationException, SecurityViolationException,
309+
ConfigurationException, ObjectNotFoundException {
310+
recordCorrelationOutcomeInShadow(aCase, result);
311+
295312
correlationService.resolve(aCase, task, result);
313+
if (result.isError()) {
314+
throw new SystemException(result.getMessage());
315+
}
296316

297317
// As a convenience, we try to re-import the object. Technically this is not a part of the correlation case processing.
298318
// Whether we do this should be made configurable (in the future).
299319

300320
String shadowOid = CorrelationCaseUtil.getShadowOidRequired(aCase);
321+
OperationResult importResult = result.createSubresult(OP_APPLY_CORRELATION_OUTCOME);
301322
try {
302-
modelService.importFromResource(shadowOid, task, result);
323+
modelService.importFromResource(shadowOid, task, importResult);
324+
importResult.computeStatusIfUnknown();
325+
if (importResult.isError()) {
326+
// Abort the case close if the real import/projector path reported an error.
327+
throw new SystemException(importResult.getMessage());
328+
}
303329
} catch (Exception e) {
304330
LoggingUtils.logUnexpectedException(LOGGER, "Couldn't import shadow {} from resource", e, shadowOid);
305-
// Not throwing the exception higher (import itself is not prerequisite for completing the correlation case
331+
throw e;
332+
} finally {
333+
importResult.close();
306334
}
335+
307336
}
308337

309-
private void recordCaseCompletionInShadow(CaseType aCase, Task task, OperationResult result)
338+
private void recordCorrelationOutcomeInShadow(CaseType aCase, OperationResult result)
310339
throws ObjectNotFoundException, SchemaException {
311-
312340
String shadowOid = CorrelationCaseUtil.getShadowOidRequired(aCase);
313-
XMLGregorianCalendar now = XmlTypeConverter.createXMLGregorianCalendar();
314341
ObjectReferenceType resultingOwnerRef =
315342
CloneUtil.clone(
316343
CorrelationCaseUtil.getResultingOwnerRef(aCase));
317344
CorrelationSituationType situation = resultingOwnerRef != null ?
318345
CorrelationSituationType.EXISTING_OWNER : CorrelationSituationType.NO_OWNER;
319346

347+
try {
348+
repositoryService.modifyObject(
349+
ShadowType.class,
350+
shadowOid,
351+
prismContext.deltaFor(ShadowType.class)
352+
.item(CORRELATION_RESULTING_OWNER_PATH)
353+
.replace(resultingOwnerRef)
354+
.item(CORRELATION_SITUATION_PATH)
355+
.replace(situation)
356+
.asItemDeltas(),
357+
result);
358+
} catch (SchemaException | ObjectAlreadyExistsException e) {
359+
throw SystemException.unexpected(e, "while recording correlation outcome in shadow");
360+
}
361+
}
362+
363+
private void recordCorrelationCompletionMetadataInShadow(CaseType aCase, Task task, OperationResult result)
364+
throws ObjectNotFoundException, SchemaException {
365+
String shadowOid = CorrelationCaseUtil.getShadowOidRequired(aCase);
366+
XMLGregorianCalendar now = XmlTypeConverter.createXMLGregorianCalendar();
367+
320368
try {
321369
repositoryService.modifyObject(
322370
ShadowType.class,
@@ -328,10 +376,6 @@ private void recordCaseCompletionInShadow(CaseType aCase, Task task, OperationRe
328376
.replaceRealValues(getPerformerRefs(aCase))
329377
.item(CORRELATION_PERFORMER_COMMENT_PATH)
330378
.replaceRealValues(getPerformerComments(aCase, task, result))
331-
.item(CORRELATION_RESULTING_OWNER_PATH)
332-
.replace(resultingOwnerRef)
333-
.item(CORRELATION_SITUATION_PATH)
334-
.replace(situation)
335379
.asItemDeltas(),
336380
result);
337381
} catch (SchemaException | ObjectAlreadyExistsException e) {

model/model-impl/src/main/java/com/evolveum/midpoint/model/impl/correlation/CorrelationServiceImpl.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,15 @@ public void completeCorrelationCase(
305305
correlationCaseManager.completeCorrelationCase(currentCase, caseCloser, task, result);
306306
}
307307

308+
@Override
309+
public void prepareCorrelationCaseClosing(
310+
@NotNull CaseType currentCase,
311+
@NotNull Task task,
312+
@NotNull OperationResult result)
313+
throws SchemaException, ObjectNotFoundException, ExpressionEvaluationException, CommunicationException, SecurityViolationException, ConfigurationException {
314+
correlationCaseManager.prepareCorrelationCaseClosing(currentCase, task, result);
315+
}
316+
308317
/**
309318
* Resolves the given correlation case - in the correlator.
310319
* (For majority of correlators this is no-op. See {@link Correlator#resolve(CaseType, String, Task, OperationResult)}.)

release-notes.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Overall, midPoint 4.9 is a major step from the past into the future.
3838
== Changes with Respect To Version 4.9.6
3939

4040
* Fixed reconc task with tailoring GUI error in Subtasks tab. See bug:MID-11107[].
41+
* Fixed correlation case closing on resolution error. See bug:MID-11094[]
4142

4243
== Changes with Respect To Version 4.9.5
4344

testing/story/src/test/java/com/evolveum/midpoint/testing/story/correlation/AbstractSimpleIdMatchCorrelationTest.java

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@
1313
import com.evolveum.midpoint.task.api.Task;
1414
import com.evolveum.midpoint.test.CsvTestResource;
1515
import com.evolveum.midpoint.test.TestObject;
16+
import com.evolveum.midpoint.util.exception.SystemException;
1617
import com.evolveum.midpoint.xml.ns._public.common.common_3.*;
1718

1819
import org.testng.annotations.Test;
1920

2021
import java.io.File;
22+
import java.util.Objects;
2123

2224
import static com.evolveum.midpoint.schema.constants.MidPointConstants.NS_RI;
2325

2426
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2528

2629
/**
2730
* Tests ID Match correlation in the most simple case:
@@ -123,15 +126,53 @@ public void test110ImportJohnFromSisDisputed() throws Exception {
123126
assertUserByUsername("smith1", "after import")
124127
.display()
125128
.assertLinks(1, 0); // The account should not be linked yet
129+
}
130+
131+
/**
132+
* Resolves the disputed case created in the previous test by selecting the existing owner.
133+
*
134+
* The resolution must fail in the real import/projector path, and the case must remain open.
135+
*/
136+
@Test
137+
public void test115ResolveDisputedCaseAsExistingOwnerFailsAndKeepsCaseOpen() throws Exception {
138+
Task task = getTestTask();
139+
OperationResult result = task.getResult();
140+
141+
given("previously created disputed case for SIS account #2");
142+
PrismObject<ShadowType> a2Before = findShadowByPrismName("2", RESOURCE_SIS.get(), result);
143+
CaseType aCaseBefore = correlationCaseManager.findCorrelationCase(a2Before.asObjectable(), true, result);
144+
assertThat(aCaseBefore).as("correlation case before resolution").isNotNull();
145+
146+
String caseOid = aCaseBefore.getOid();
126147

127148
when("resolving the case");
128-
resolveCase(aCase, johnOid, task, result);
149+
assertThatThrownBy(() -> resolveCase(aCaseBefore, johnOid, task, result))
150+
.isInstanceOf(SystemException.class)
151+
.hasMessageContaining("already exists in lens context");
129152

130-
then("error should be reported");
131-
assertFailure(result);
153+
then("error should be reported and the case should remain open");
154+
assertResultTreeContainsMessage(result);
132155

133-
// May be fragile. Adapt as needed.
134-
assertThat(result.getMessage()).as("error message").contains("already exists in lens context");
156+
CaseType caseAfter = getCase(caseOid);
157+
assertCase(caseAfter, "case after failed resolution")
158+
.display()
159+
.assertOpen()
160+
.workItems()
161+
.single()
162+
.assertNotClosed();
163+
164+
PrismObject<ShadowType> a2After = findShadowByPrismName("2", RESOURCE_SIS.get(), result);
165+
ShadowCorrelationStateType correlationState = a2After.asObjectable().getCorrelation();
166+
assertThat(correlationState).as("correlation state").isNotNull();
167+
assertThat(correlationState.getCorrelationCaseCloseTimestamp())
168+
.as("correlation case close timestamp")
169+
.isNull();
170+
assertThat(correlationState.getPerformerRef())
171+
.as("correlation performer refs")
172+
.isEmpty();
173+
assertThat(correlationState.getPerformerComment())
174+
.as("correlation performer comments")
175+
.isEmpty();
135176

136177
assertUserByUsername("smith1", "after case resolution")
137178
.display()
@@ -191,4 +232,14 @@ public void test120ImportJohnFromSisDisputedAgain() throws Exception {
191232
.display()
192233
.assertLinks(1, 0);
193234
}
235+
236+
private void assertResultTreeContainsMessage(OperationResult result) {
237+
assertThat(
238+
result.getResultStream()
239+
.map(OperationResult::getMessage)
240+
.filter(Objects::nonNull)
241+
.anyMatch(message -> message.contains("already exists in lens context")))
242+
.as("operation result tree should contain message fragment '%s'", "already exists in lens context")
243+
.isTrue();
244+
}
194245
}

0 commit comments

Comments
 (0)