From d60374fa92f07896da2c96bcc48a62b59f648473 Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Mon, 21 Jun 2021 16:40:05 +0200 Subject: [PATCH] Handled polling of moveNode operations --- .../ia2/vospace/ui/client/VOSpaceClient.java | 16 +++++ .../ui/controller/NodesController.java | 31 +++++++- .../vospace/ui/client/VOSpaceClientTest.java | 10 +++ .../ui/controller/NodesControllerTest.java | 72 +++++++++++++++++++ vospace-ui-frontend/src/store.js | 13 ++-- 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java index b1088d3..819a285 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java @@ -35,6 +35,7 @@ import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import javax.xml.bind.JAXB; +import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.uws.v1.Jobs; import net.ivoa.xml.vospace.v2.Node; @@ -189,6 +190,21 @@ public class VOSpaceClient { }); } + public ExecutionPhase getJobPhase(String jobId) { + + HttpRequest request = getRequest("/transfers/" + jobId + "/phase") + .GET() + .build(); + + return call(request, BodyHandlers.ofInputStream(), 200, res -> { + try { + return ExecutionPhase.valueOf(new String(res.readAllBytes())); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } + public String getErrorDetail(String jobId) { HttpRequest request = getRequest("/transfers/" + jobId + "/error") diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java index 0b44715..fe5ffa6 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java @@ -7,7 +7,9 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.aa.data.User; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.Job; import it.inaf.ia2.vospace.ui.data.ListNodeData; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator; import it.inaf.ia2.vospace.ui.service.MoveNodeModalHtmlGenerator; import it.inaf.oats.vospace.datamodel.NodeUtils; @@ -18,6 +20,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Node; @@ -46,6 +49,9 @@ public class NodesController extends BaseController { @Value("${vospace-authority}") private String authority; + @Value("${maxPollingAttempts:10}") + private int maxPollingAttempts; + @Autowired private VOSpaceClient client; @@ -154,7 +160,7 @@ public class NodesController extends BaseController { } @PostMapping(value = "/move") - public void moveNode(@RequestBody Map<String, Object> params) { + public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) { String target = getRequiredParam(params, "target"); String direction = getRequiredParam(params, "direction"); @@ -165,7 +171,28 @@ public class NodesController extends BaseController { JobSummary job = client.startTransferJob(transfer); - // TODO: polling + // Try to perform polling until completion. If it takes too much time + // sends the execution phase to the UI and let it handles the polling. + ExecutionPhase phase; + int i = 0; + do { + phase = client.getJobPhase(job.getJobId()); + if (phase == ExecutionPhase.COMPLETED) { + break; + } else if (phase == ExecutionPhase.ERROR) { + String errorDetail = client.getErrorDetail(job.getJobId()); + throw new VOSpaceException("MoveNode operation failed: " + errorDetail); + } + try { + Thread.sleep(1000); + } catch (InterruptedException ex) { + } + i++; + } while (i < maxPollingAttempts); + + job.setPhase(phase); + + return ResponseEntity.ok(new Job(job)); } protected String getPath(String prefix) { diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java index 0a3a110..a816220 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Optional; import java.util.concurrent.CompletableFuture; import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Protocol; import net.ivoa.xml.vospace.v2.Transfer; @@ -186,6 +187,15 @@ public class VOSpaceClientTest { assertEquals("Protocol negotiation failed", ex.getMessage()); } + @Test + public void testGetJobPhase() { + + CompletableFuture response = getMockedStreamResponseFuture(200, "COMPLETED"); + when(mockedHttpClient.sendAsync(any(), any())).thenReturn(response); + + assertEquals(ExecutionPhase.COMPLETED, voSpaceClient.getJobPhase("job_id")); + } + protected static String getResourceFileContent(String fileName) { try ( InputStream in = VOSpaceClientTest.class.getClassLoader().getResourceAsStream(fileName)) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java index 75571a3..5879848 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java @@ -7,12 +7,19 @@ package it.inaf.ia2.vospace.ui.controller; import com.fasterxml.jackson.databind.ObjectMapper; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.Job; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import java.util.Arrays; import java.util.List; +import java.util.Map; +import net.ivoa.xml.uws.v1.ExecutionPhase; +import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.DataNode; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import org.junit.jupiter.api.Test; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -25,7 +32,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -33,6 +42,7 @@ import org.springframework.web.util.NestedServletException; @SpringBootTest @AutoConfigureMockMvc +@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "maxPollingAttempts=2"}) public class NodesControllerTest { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -139,4 +149,66 @@ public class NodesControllerTest { verify(client, times(1)).getNode(eq("/a/b/c")); } + + @Test + public void testMoveNodeSuccess() throws Exception { + + when(client.getJobPhase("job_id")) + .thenReturn(ExecutionPhase.EXECUTING) + .thenReturn(ExecutionPhase.COMPLETED); + + String response = testMoveNode() + .andExpect(status().isOk()) + .andReturn().getResponse() + .getContentAsString(); + + Job job = MAPPER.readValue(response, Job.class); + assertEquals(ExecutionPhase.COMPLETED, job.getPhase()); + } + + @Test + public void testMoveNodeExecuting() throws Exception { + + when(client.getJobPhase("job_id")) + .thenReturn(ExecutionPhase.EXECUTING); + + String response = testMoveNode() + .andExpect(status().isOk()) + .andReturn().getResponse() + .getContentAsString(); + + Job job = MAPPER.readValue(response, Job.class); + assertEquals(ExecutionPhase.EXECUTING, job.getPhase()); + } + + @Test + public void testMoveNodeError() throws Exception { + + when(client.getJobPhase("job_id")) + .thenReturn(ExecutionPhase.ERROR); + + when(client.getErrorDetail("job_id")).thenReturn("move_error"); + + try { + testMoveNode(); + fail("Exception was expected"); + } catch (Exception ex) { + assertTrue(ex.getCause() instanceof VOSpaceException); + assertTrue(ex.getCause().getMessage().contains("move_error")); + } + } + + private ResultActions testMoveNode() throws Exception { + + JobSummary job = new JobSummary(); + job.setJobId("job_id"); + + when(client.startTransferJob(any())).thenReturn(job); + + Map<String, String> params = Map.of("target", "/path/to/target", "direction", "/path/to/direction"); + + return mockMvc.perform(post("/move") + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(params))); + } } diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index f61753d..ffa8604 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -207,11 +207,16 @@ export default new Vuex.Store({ dispatch('setPath', state.path); }); }, - moveNode({ state, dispatch }, data) { + moveNode({ state, commit, dispatch }, data) { client.moveNode(data) - .then(() => { - // Reload current node - dispatch('setPath', state.path); + .then(job => { + if (job.phase === 'COMPLETED') { + // Reload current node + dispatch('setPath', state.path); + } else { + main.showInfo('Move operation is taking some time and will be handled in background'); + commit('addJob', job); + } }); }, openNodeInMoveModal({ state, commit }, path) { -- GitLab