Skip to content

Commit 6ff2df0

Browse files
karanh37claudeyan-3005
authored
Fixes #31692: remove reviewer→approval-task assignee sync from repositories (#32912)
* Fixes #31692: wire GlossaryTerm approval-task reviewer sync GlossaryTermRepository.updateTaskWithNewReviewers() existed but had no caller, unlike the five sibling repositories (Tag, TestCase, DataProduct, DataContract, Metric) that invoke it from an updateReviewers() override. Reviewer/assignee consistency relied solely on the seeded Glossary Approval Workflow re-triggering, which supersedes the open approval task with a new one (new task id) rather than patching it in place, orphaning comment history and coupling the guarantee to workflow configuration. Add an updateReviewers() override in GlossaryTermUpdater following the TagRepository pattern: when the reviewer list changes it patches the existing open approval task's assignees in place. updateApprovalTaskAssignees returns early when no open task exists, so terms without an approval workflow are unaffected. Add GlossaryTermReviewerTaskSyncIT: creates a term with one reviewer, waits for its open approval task, adds a second reviewer, and asserts the original task (fetched by id) is patched to include the new reviewer. Because the repository patch is synchronous within the PATCH transaction and the workflow safety net can only open a new task, this isolates the repository mechanism from the workflow without disabling the seeded workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fixes #31692: remove reviewer→approval-task assignee sync from repositories updateTaskWithNewReviewers / TaskRepository.updateApprovalTaskAssignees overwrote an open approval task's assignees with the entity's reviewers whenever the reviewer list changed. That is wrong: a userApprovalTask chooses its assignees by rule (owners, reviewers, individual users or teams), so forcing the reviewer list onto the task corrupts any task not configured to assign reviewers. Task assignees are a point-in-time snapshot owned by the workflow, not by the repository. Remove the mechanism everywhere: - updateReviewers() overrides in Tag, TestCase, DataProduct, DataContract, Metric (were live) - dead updateTaskWithNewReviewers in GlossaryTerm and KnowledgePage (never called) plus GlossaryTerm's now-unused resolveEffectiveReviewers - TaskRepository.updateApprovalTaskAssignees (no remaining callers) This also reverts the wiring added earlier on this branch. Behavior note: Tag/TestCase/DataProduct/DataContract/Metric have no seeded approval workflow, so for a custom approval workflow this sync was the only thing that moved an open task's assignees on a reviewer change. After this change, changing reviewers leaves an existing open approval task's assignees untouched — the intended semantics. GlossaryTerm and AI assets have seeded workflows that already supersede the open task with a fresh one assigned to the current reviewers, so their user-facing behavior is unchanged. Replace the prior in-place-patch IT with GlossaryTermReviewerChangeApprovalIT, which asserts the acceptance criterion: adding a reviewer to an in-review term leaves exactly one open approval task assigned to the new reviewers. * test: assert reviewer change supersedes the open approval task Require the settled open approval task to be a NEW task (id != original) assigned to the added reviewer, and fail fast with diagnostics if the approval workflow settles terminally without producing it, instead of a bare Awaitility timeout. * test: cover removal with unit + runtime approval-assignee tests Replace the glossary supersession IT (which passed both before and after the removal) with tests that target the invariant directly: - SetApprovalAssigneesImplTest: an owners-only and an explicit-users userApprovalTask rule must not pull the entity's reviewers into the task's assignees — the principle the removed repository sync violated. - WorkflowDefinitionResourceIT#test_reviewerChangeDoesNotOverwriteOwnerAssignedApprovalTask: a custom approval workflow assigns its task to owners on a tag (Group-2 entity), triggered on Created only so a reviewer change cannot re-trigger it. Changing the tag's reviewers must leave the owner-assigned task's assignees untouched. This fails without the removal: the repository overwrote the open task's assignees with the reviewer list in the PATCH. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ram Narayan Balaji <ramnarayanb3005@gmail.com> Co-authored-by: Ram Narayan Balaji <81347100+yan-3005@users.noreply.github.com>
1 parent 09a4959 commit 6ff2df0

10 files changed

Lines changed: 239 additions & 203 deletions

File tree

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/WorkflowDefinitionResourceIT.java

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
import java.util.LinkedHashMap;
2828
import java.util.List;
2929
import java.util.Map;
30+
import java.util.Set;
3031
import java.util.UUID;
3132
import java.util.concurrent.ConcurrentHashMap;
3233
import java.util.function.BiConsumer;
3334
import java.util.function.Supplier;
35+
import java.util.stream.Collectors;
3436
import java.util.stream.Stream;
3537
import org.awaitility.core.ConditionTimeoutException;
3638
import org.flowable.engine.ManagementService;
@@ -48,6 +50,7 @@
4850
import org.junit.jupiter.api.parallel.Execution;
4951
import org.junit.jupiter.api.parallel.ExecutionMode;
5052
import org.junit.jupiter.api.parallel.Isolated;
53+
import org.openmetadata.it.bootstrap.SharedEntities;
5154
import org.openmetadata.it.factories.MlModelServiceTestFactory;
5255
import org.openmetadata.it.util.SdkClients;
5356
import org.openmetadata.it.util.TestNamespace;
@@ -3626,6 +3629,190 @@ void test_MixedEntityTypesWithReviewerSupport() {
36263629
LOG.info("test_MixedEntityTypesWithReviewerSupport completed successfully");
36273630
}
36283631

3632+
/**
3633+
* Regression guard for #31692. A custom approval workflow assigns its task to <b>owners</b>
3634+
* (addOwners=true, addReviewers=false) on a {@code tag} — a Group-2 entity that used to wire
3635+
* {@code updateReviewers() -> updateTaskWithNewReviewers}. The workflow triggers on {@code Created}
3636+
* only, so changing the tag's reviewers afterwards cannot re-trigger it; the ONLY thing that could
3637+
* mutate the open task's assignees on a reviewer change was the removed repository sync. After the
3638+
* removal, changing reviewers must leave the owner-assigned approval task's assignees untouched —
3639+
* the added reviewer must NOT be injected as an approver. Before the removal this test fails: the
3640+
* repository overwrites the task's assignees with the reviewer list inside the PATCH transaction.
3641+
*/
3642+
@Test
3643+
@Order(220)
3644+
void test_reviewerChangeDoesNotOverwriteOwnerAssignedApprovalTask(TestNamespace ns)
3645+
throws Exception {
3646+
LOG.info("Starting test_reviewerChangeDoesNotOverwriteOwnerAssignedApprovalTask");
3647+
OpenMetadataClient client = SdkClients.adminClient();
3648+
SharedEntities shared = SharedEntities.get();
3649+
ensureWorkflowEventConsumerIsActive(client);
3650+
3651+
String workflowName = "ownerAssignedTagApproval_" + UUID.randomUUID();
3652+
String workflowJson =
3653+
"""
3654+
{
3655+
"name": "%s",
3656+
"displayName": "Owner Assigned Tag Approval",
3657+
"description": "Approval task assigned to owners, triggered on tag creation only",
3658+
"trigger": {
3659+
"type": "eventBasedEntity",
3660+
"config": {
3661+
"entityTypes": ["tag"],
3662+
"events": ["Created"]
3663+
},
3664+
"output": ["relatedEntity", "updatedBy"]
3665+
},
3666+
"nodes": [
3667+
{"name": "start", "displayName": "Start", "type": "startEvent", "subType": "startEvent"},
3668+
{
3669+
"name": "ApproveTag",
3670+
"displayName": "Approve Tag",
3671+
"type": "userTask",
3672+
"subType": "userApprovalTask",
3673+
"config": {
3674+
"assignees": {"addReviewers": false, "addOwners": true, "candidates": []},
3675+
"approvalThreshold": 1,
3676+
"rejectionThreshold": 1,
3677+
"stageId": "review",
3678+
"stageDisplayName": "Review",
3679+
"taskStatus": "Open",
3680+
"assigneeStrategy": "reviewers-and-assignees",
3681+
"transitionMetadata": [
3682+
{"id": "approve", "label": "Approve", "targetStageId": "approved", "targetTaskStatus": "Approved", "resolutionType": "Approved", "formRef": "approve", "requiresComment": false},
3683+
{"id": "reject", "label": "Reject", "targetStageId": "rejected", "targetTaskStatus": "Rejected", "resolutionType": "Rejected", "formRef": "reject", "requiresComment": true}
3684+
]
3685+
},
3686+
"inputNamespaceMap": {"relatedEntity": "global"}
3687+
},
3688+
{"name": "endApproved", "displayName": "End Approved", "type": "endEvent", "subType": "endEvent"},
3689+
{"name": "endRejected", "displayName": "End Rejected", "type": "endEvent", "subType": "endEvent"}
3690+
],
3691+
"edges": [
3692+
{"from": "start", "to": "ApproveTag"},
3693+
{"from": "ApproveTag", "to": "endApproved", "condition": "approve"},
3694+
{"from": "ApproveTag", "to": "endRejected", "condition": "reject"}
3695+
],
3696+
"config": {"storeStageStatus": true}
3697+
}
3698+
"""
3699+
.formatted(workflowName);
3700+
3701+
String createResponse =
3702+
client
3703+
.getHttpClient()
3704+
.executeForString(
3705+
HttpMethod.POST,
3706+
BASE_PATH,
3707+
MAPPER.readValue(workflowJson, CreateWorkflowDefinition.class),
3708+
RequestOptions.builder().build());
3709+
JsonNode created = MAPPER.readTree(createResponse);
3710+
assertTrue(created.has("id"));
3711+
trackWorkflowFromJson(created);
3712+
waitForWorkflowDeployment(client, workflowName);
3713+
ensureWorkflowEventConsumerIsActive(client);
3714+
3715+
// Classification + tag owned by USER1 with USER2 as an initial reviewer.
3716+
Classification classification =
3717+
client
3718+
.classifications()
3719+
.create(
3720+
new CreateClassification()
3721+
.withName(
3722+
ns.prefix("revguard")
3723+
.substring(0, Math.min(25, ns.prefix("revguard").length())))
3724+
.withDescription("Classification for reviewer-change approval guard"));
3725+
CreateTag createTag =
3726+
new CreateTag()
3727+
.withName("guardTag")
3728+
.withClassification(classification.getFullyQualifiedName())
3729+
.withDescription("Owner-assigned approval guard tag")
3730+
.withOwners(List.of(shared.USER1_REF))
3731+
.withReviewers(List.of(shared.USER2_REF));
3732+
Tag tag = client.tags().create(createTag);
3733+
3734+
Task task = awaitOpenApprovalTaskForEntity(tag.getFullyQualifiedName());
3735+
Set<UUID> assignees = assigneeIds(task);
3736+
assertTrue(
3737+
assignees.contains(shared.USER1.getId()),
3738+
"Owner must be assigned to the approval task, assignees=" + assignees);
3739+
assertFalse(
3740+
assignees.contains(shared.USER2.getId()),
3741+
"Reviewer must not be assigned to an owners-only approval task, assignees=" + assignees);
3742+
3743+
// Change reviewers: add USER3. The Created-only workflow cannot re-trigger on this Updated
3744+
// event.
3745+
JsonNode reviewerPatch =
3746+
MAPPER.readTree(
3747+
String.format(
3748+
"[{\"op\":\"replace\",\"path\":\"/reviewers\",\"value\":"
3749+
+ "[{\"id\":\"%s\",\"type\":\"user\"},{\"id\":\"%s\",\"type\":\"user\"}]}]",
3750+
shared.USER2.getId(), shared.USER3.getId()));
3751+
client.tags().patch(tag.getId().toString(), reviewerPatch);
3752+
3753+
// The open approval task's assignees must be unchanged — still the owner, never the reviewers.
3754+
Task afterPatch = openApprovalTaskById(tag.getFullyQualifiedName(), task.getId());
3755+
assertNotNull(
3756+
afterPatch, "The owner-assigned approval task must remain open after the reviewer change");
3757+
Set<UUID> afterAssignees = assigneeIds(afterPatch);
3758+
assertTrue(
3759+
afterAssignees.contains(shared.USER1.getId()),
3760+
"Owner must remain assigned after the reviewer change, assignees=" + afterAssignees);
3761+
assertFalse(
3762+
afterAssignees.contains(shared.USER3.getId()),
3763+
"A newly added reviewer must not be injected into an owners-only task, assignees="
3764+
+ afterAssignees);
3765+
assertFalse(
3766+
afterAssignees.contains(shared.USER2.getId()),
3767+
"Reviewers must not leak into an owners-only task, assignees=" + afterAssignees);
3768+
3769+
LOG.info("test_reviewerChangeDoesNotOverwriteOwnerAssignedApprovalTask completed successfully");
3770+
}
3771+
3772+
private Task awaitOpenApprovalTaskForEntity(String entityFqn) {
3773+
await("open approval task for " + entityFqn)
3774+
.atMost(Duration.ofMinutes(5))
3775+
.pollInterval(Duration.ofSeconds(2))
3776+
.until(() -> !openApprovalTasks(entityFqn).isEmpty());
3777+
List<Task> tasks = openApprovalTasks(entityFqn);
3778+
assertFalse(tasks.isEmpty(), "Expected an open approval task for " + entityFqn);
3779+
return tasks.get(0);
3780+
}
3781+
3782+
private Task openApprovalTaskById(String entityFqn, UUID taskId) {
3783+
return openApprovalTasks(entityFqn).stream()
3784+
.filter(t -> taskId.equals(t.getId()))
3785+
.findFirst()
3786+
.orElse(null);
3787+
}
3788+
3789+
private List<Task> openApprovalTasks(String entityFqn) {
3790+
List<Task> tasks;
3791+
try {
3792+
ListResponse<Task> response =
3793+
SdkClients.adminClient()
3794+
.tasks()
3795+
.listWithFilters(
3796+
Map.of(
3797+
"limit",
3798+
"100",
3799+
"status",
3800+
TaskEntityStatus.Open.value(),
3801+
"aboutEntity",
3802+
entityFqn));
3803+
tasks = response.getData() == null ? List.of() : response.getData();
3804+
} catch (RuntimeException e) {
3805+
tasks = List.of();
3806+
}
3807+
return tasks;
3808+
}
3809+
3810+
private Set<UUID> assigneeIds(Task task) {
3811+
return task.getAssignees() == null
3812+
? Set.of()
3813+
: task.getAssignees().stream().map(EntityReference::getId).collect(Collectors.toSet());
3814+
}
3815+
36293816
@Test
36303817
@Order(34)
36313818
void test_WorkflowValidationEndpoint() {

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataContractRepository.java

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,16 +1507,6 @@ public DataContractUpdater(
15071507
super(original, updated, operation, changeSource);
15081508
}
15091509

1510-
@Override
1511-
public void updateReviewers() {
1512-
super.updateReviewers();
1513-
if (original.getReviewers() != null
1514-
&& updated.getReviewers() != null
1515-
&& !original.getReviewers().equals(updated.getReviewers())) {
1516-
updateTaskWithNewReviewers(updated);
1517-
}
1518-
}
1519-
15201510
@Override
15211511
public void entitySpecificUpdate(boolean consolidatingChanges) {
15221512
preserveUnspecifiedODCSPassthrough();
@@ -1915,18 +1905,4 @@ private void closeApprovalTask(DataContract entity, String comment) {
19151905
taskRepository.closeApprovalTaskForEntity(
19161906
entity.getFullyQualifiedName(), entity.getUpdatedBy(), comment);
19171907
}
1918-
1919-
protected void updateTaskWithNewReviewers(DataContract dataContract) {
1920-
dataContract =
1921-
Entity.getEntityByName(
1922-
Entity.DATA_CONTRACT,
1923-
dataContract.getFullyQualifiedName(),
1924-
"id,fullyQualifiedName,reviewers",
1925-
Include.ALL);
1926-
TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK);
1927-
taskRepository.updateApprovalTaskAssignees(
1928-
dataContract.getFullyQualifiedName(),
1929-
new ArrayList<>(dataContract.getReviewers()),
1930-
dataContract.getUpdatedBy());
1931-
}
19321908
}

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -949,16 +949,6 @@ protected void resetForRetryAttempt() {
949949
capturedUpdatedDomains = null;
950950
}
951951

952-
@Override
953-
public void updateReviewers() {
954-
super.updateReviewers();
955-
if (original.getReviewers() != null
956-
&& updated.getReviewers() != null
957-
&& !original.getReviewers().equals(updated.getReviewers())) {
958-
updateTaskWithNewReviewers(updated);
959-
}
960-
}
961-
962952
public List<EntityReference> getCapturedOriginalDomains() {
963953
return capturedOriginalDomains;
964954
}
@@ -1259,20 +1249,6 @@ private void closeApprovalTask(DataProduct entity, String comment) {
12591249
entity.getFullyQualifiedName(), entity.getUpdatedBy(), comment);
12601250
}
12611251

1262-
protected void updateTaskWithNewReviewers(DataProduct dataProduct) {
1263-
dataProduct =
1264-
Entity.getEntityByName(
1265-
Entity.DATA_PRODUCT,
1266-
dataProduct.getFullyQualifiedName(),
1267-
"id,fullyQualifiedName,reviewers",
1268-
Include.ALL);
1269-
TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK);
1270-
taskRepository.updateApprovalTaskAssignees(
1271-
dataProduct.getFullyQualifiedName(),
1272-
new ArrayList<>(dataProduct.getReviewers()),
1273-
dataProduct.getUpdatedBy());
1274-
}
1275-
12761252
public org.openmetadata.schema.entity.data.DataContract getDataProductContract(
12771253
UUID dataProductId) {
12781254
try {

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,44 +2512,6 @@ private List<GlossaryTerm> getNestedTerms(GlossaryTerm glossaryTerm) {
25122512
return JsonUtils.readObjects(jsons, GlossaryTerm.class);
25132513
}
25142514

2515-
protected void updateTaskWithNewReviewers(GlossaryTerm term) {
2516-
term =
2517-
Entity.getEntityByName(
2518-
Entity.GLOSSARY_TERM,
2519-
term.getFullyQualifiedName(),
2520-
"id,fullyQualifiedName,reviewers,parent,glossary",
2521-
Include.ALL);
2522-
TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK);
2523-
taskRepository.updateApprovalTaskAssignees(
2524-
term.getFullyQualifiedName(),
2525-
new ArrayList<>(resolveEffectiveReviewers(term)),
2526-
term.getUpdatedBy());
2527-
}
2528-
2529-
private List<EntityReference> resolveEffectiveReviewers(GlossaryTerm term) {
2530-
if (!nullOrEmpty(term.getReviewers())) {
2531-
return term.getReviewers();
2532-
}
2533-
2534-
if (term.getParent() != null) {
2535-
GlossaryTerm parentTerm =
2536-
Entity.getEntity(
2537-
term.getParent().withType(GLOSSARY_TERM), "reviewers", Include.NON_DELETED);
2538-
if (!nullOrEmpty(parentTerm.getReviewers())) {
2539-
return parentTerm.getReviewers();
2540-
}
2541-
}
2542-
2543-
if (term.getGlossary() != null) {
2544-
Glossary glossary = Entity.getEntity(term.getGlossary(), "reviewers", Include.NON_DELETED);
2545-
if (!nullOrEmpty(glossary.getReviewers())) {
2546-
return glossary.getReviewers();
2547-
}
2548-
}
2549-
2550-
return List.of();
2551-
}
2552-
25532515
private void fetchAndSetRelatedTerms(List<GlossaryTerm> entities, Fields fields) {
25542516
if (!fields.contains("relatedTerms") || entities.isEmpty()) {
25552517
return;

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/KnowledgePageRepository.java

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -745,20 +745,6 @@ private void updateArticles(Page original, Page updated) {
745745
}
746746
}
747747

748-
protected void updateTaskWithNewReviewers(Page page) {
749-
Page currentPage =
750-
Entity.getEntityByName(
751-
KNOWLEDGE_PAGE_ENTITY,
752-
page.getFullyQualifiedName(),
753-
"id,fullyQualifiedName,reviewers",
754-
Include.ALL);
755-
TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK);
756-
taskRepository.updateApprovalTaskAssignees(
757-
currentPage.getFullyQualifiedName(),
758-
new ArrayList<>(currentPage.getReviewers()),
759-
currentPage.getUpdatedBy());
760-
}
761-
762748
@Override
763749
public void postUpdate(Page original, Page updated) {
764750
super.postUpdate(original, updated);

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -471,16 +471,6 @@ public MetricUpdater(Metric original, Metric updated, Operation operation) {
471471
super(original, updated, operation);
472472
}
473473

474-
@Override
475-
public void updateReviewers() {
476-
super.updateReviewers();
477-
if (original.getReviewers() != null
478-
&& updated.getReviewers() != null
479-
&& !original.getReviewers().equals(updated.getReviewers())) {
480-
updateTaskWithNewReviewers(updated);
481-
}
482-
}
483-
484474
@Transaction
485475
@Override
486476
public void entitySpecificUpdate(boolean consolidatingChanges) {
@@ -701,18 +691,4 @@ private void closeApprovalTask(Metric entity, String comment) {
701691
taskRepository.closeApprovalTaskForEntity(
702692
entity.getFullyQualifiedName(), entity.getUpdatedBy(), comment);
703693
}
704-
705-
protected void updateTaskWithNewReviewers(Metric metric) {
706-
metric =
707-
Entity.getEntityByName(
708-
Entity.METRIC,
709-
metric.getFullyQualifiedName(),
710-
"id,fullyQualifiedName,reviewers",
711-
Include.ALL);
712-
TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK);
713-
taskRepository.updateApprovalTaskAssignees(
714-
metric.getFullyQualifiedName(),
715-
new ArrayList<>(metric.getReviewers()),
716-
metric.getUpdatedBy());
717-
}
718694
}

0 commit comments

Comments
 (0)