diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java index 19b693bd3c..3942dcf9dd 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/MerlinDatabaseTestHelper.java @@ -148,7 +148,6 @@ int insertActivity(final int planId, final String startOffset, final String type return insertActivity(planId, startOffset, type, arguments, admin); } - int insertActivity(final int planId, final String startOffset, final String arguments, User user) throws SQLException { try (final var statement = connection.createStatement()) { final var res = statement @@ -294,7 +293,7 @@ void assignPreset(int presetId, int activityId, int planId, String userSession) } } -void unassignPreset(int presetId, int activityId, int planId) throws SQLException { + void unassignPreset(int presetId, int activityId, int planId) throws SQLException { try(final var statement = connection.createStatement()){ statement.execute( //language=sql @@ -305,7 +304,6 @@ void unassignPreset(int presetId, int activityId, int planId) throws SQLExceptio } } - int insertConstraint(String name, String definition, User user) throws SQLException { try(final var statement = connection.createStatement()) { final var res = statement.executeQuery( @@ -325,4 +323,62 @@ WITH metadata(id, owner) AS ( return res.getInt("constraint_id"); } } + + void updatePlanDuration(int planId, String newDuration) throws SQLException { + try(final var statement = connection.createStatement()) { + statement.executeUpdate( + //language=sql + """ + update merlin.plan + set duration = '%s' + where id = %d + """.formatted(newDuration, planId) + ); + } + } + + int getPlanRevision(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select revision + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getInt("revision"); + } + } + + String getPlanStartTime(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select start_time + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getString("start_time"); + } + } + + String getPlanDuration(int planId) throws SQLException { + try(final var statement = connection.createStatement()) { + final var res = statement.executeQuery( + //language=sql + """ + select duration + from merlin.plan + where id = %d + """.formatted(planId) + ); + res.next(); + return res.getString("duration"); + } + } } diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java index c0d4df8da4..8a825d3bcf 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PlanCollaborationTests.java @@ -21,10 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import gov.nasa.jpl.aerie.database.TagsTests.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; @SuppressWarnings("SqlSourceToSinkFlow") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -214,8 +217,12 @@ private SnapshotMetadata getSnapshotMetadata(final int snapshotId) throws SQLExc return new SnapshotMetadata( res.getInt("snapshot_id"), res.getInt("plan_id"), + res.getInt("model_id"), res.getInt("revision"), + res.getString("plan_start_time"), + res.getString("plan_duration"), res.getString("snapshot_name"), + res.getString("description"), res.getString("taken_by"), res.getString("taken_at") ); @@ -615,8 +622,12 @@ public record Activity( private record SnapshotMetadata( int snapshot_id, int plan_id, + int model_id, int revision, + String planStartTime, + String planDuration, String snapshot_name, + String description, String taken_by, String taken_at) {} private record SnapshotActivity( @@ -725,7 +736,7 @@ void snapshotInheritsAllLatestAsParents() throws SQLException{ } //assert that the snapshot history is n+1 long - assertEquals(snapshotHistory.size(), numberOfSnapshots + 1); + assertEquals(numberOfSnapshots + 1, snapshotHistory.size()); //assert that res contains, in order: finalSnapshotId, snapshotId[0,1,...,n] assertEquals(finalSnapshotId, snapshotHistory.get(0)); @@ -736,16 +747,9 @@ void snapshotInheritsAllLatestAsParents() throws SQLException{ } @Test - void snapshotFailsForNonexistentPlanId() throws SQLException{ - try { - createSnapshot(1000); - fail(); - } - catch(SQLException sqlEx) - { - if(!sqlEx.getMessage().contains("Plan 1000 does not exist.")) - throw sqlEx; - } + void snapshotFailsForNonexistentPlanId() { + final var ex = assertThrows(SQLException.class, () -> createSnapshot(1000)); + assertTrue(ex.getMessage().contains("Plan 1000 does not exist.")); } @Test @@ -758,17 +762,15 @@ void createNamedSnapshot() throws SQLException { assertEquals(0, snapshot.revision); } + /** + * Snapshots can have duplicate names, as they have a description field to help + * disambiguate them + */ @Test - void namedSnapshotsMustBeUnique() throws SQLException{ + void namedSnapshotsMayHaveDuplicateNames() throws SQLException{ final var planId = merlinHelper.insertPlan(missionModelId); createSnapshot(planId, "Snapshot", merlinHelper.admin); - try { - createSnapshot(planId, "Snapshot", merlinHelper.admin); - } catch (SQLException ex) { - if (!ex.getMessage().contains("duplicate key value violates unique constraint \"snapshot_name_unique_per_plan\"")) { - throw ex; - } - } + assertDoesNotThrow(() -> createSnapshot(planId, "Snapshot", merlinHelper.admin)); } @Test @@ -789,6 +791,36 @@ void canCreateMultipleNamedSnapshots() throws SQLException{ assertEquals(merlinHelper.admin.name(), firstSnapshot.taken_by); assertEquals(merlinHelper.user.name(), secondSnapshot.taken_by); } + + /** + * A snapshot is automatically taken when the plan bounds are updated. + */ + @Test + void snapshotTakenOnPlanBoundsUpdate() throws SQLException { + final var planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name()); + assertTrue(getLatestSnapshots(planId).isEmpty()); + + // Update plan bounds + merlinHelper.updatePlanDuration(planId, "28:00:00"); + + final var latestSnapshots = getLatestSnapshots(planId); + assertEquals(1, latestSnapshots.size()); + + // Check the snapshot's contents + final var snapshot = getSnapshotMetadata(latestSnapshots.getFirst()); + assertEquals("Plan Bound Adjustment", snapshot.snapshot_name); + assertEquals("Automatic snapshot made before adjusting plan bounds from " + + "[2020-01-01 00:00:00+00 - 2020-01-01 00:00:00+00] to " + + "[2020-01-01 00:00:00+00 - 2020-01-02 04:00:00+00]", snapshot.description); + assertEquals(planId, snapshot.plan_id); + assertEquals(missionModelId, snapshot.model_id); + assertEquals("2020-01-01 00:00:00+00", snapshot.planStartTime); + assertEquals("00:00:00", snapshot.planDuration); + + // Assert that the snapshot was taken BEFORE the plan's revision was updated + assertEquals(0, snapshot.revision); + assertTrue(merlinHelper.getPlanRevision(planId) > 0); + } } @Nested @@ -796,25 +828,15 @@ class RestorePlanSnapshotTests{ @Test void restoreFailsForNonexistentPlan() throws SQLException { final int snapshotId = createSnapshot(merlinHelper.insertPlan(missionModelId)); - try { - restoreFromSnapshot(-1, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Plan with ID -1 does not exist.")) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(-1, snapshotId)); + assertTrue(ex.getMessage().contains("Cannot Restore: Plan with ID -1 does not exist.")); } @Test void restoreFailsForNonexistentSnapshot() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); - try { - restoreFromSnapshot(planId, -1); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot with ID -1 does not exist.")) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, -1)); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot with ID -1 does not exist.")); } @Test @@ -823,14 +845,9 @@ void cannotRestoreSnapshotOfDifferentPlan() throws SQLException { final int snapshotId = createSnapshot(wrongPlan); final int planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name(), "Other Plan"); - try { - restoreFromSnapshot(planId, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" - .formatted(snapshotId, planId))) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(planId, snapshotId)); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Other Plan' (ID %d)" + .formatted(snapshotId, planId))); } @Test @@ -839,14 +856,9 @@ void cannotRestoreBranchToParentSnapshot() throws SQLException { final int snapshotId = createSnapshot(wrongPlan); final int branchId = duplicatePlan(wrongPlan, "Different Plan"); - try{ - restoreFromSnapshot(branchId, snapshotId); - } catch (SQLException ex) { - if (!ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" - .formatted(snapshotId, branchId))) { - throw ex; - } - } + final var ex = assertThrows(SQLException.class, () -> restoreFromSnapshot(branchId, snapshotId)); + assertTrue(ex.getMessage().contains("Cannot Restore: Snapshot %d is not a snapshot of Plan 'Different Plan' (ID %d)" + .formatted(snapshotId, branchId))); } @Test @@ -909,6 +921,48 @@ void restoresChangedActivities() throws SQLException { final Activity restoredDirective = planActivities.get(0); assertActivityEquals(oldDirective, restoredDirective); } + + /** + * If plan bounds are updated after a snapshot is taken, then restoring the snapshot + * restores the boundaries to those at the time of the snapshot. + */ + @Test + void restoresPlanBounds() throws SQLException { + final var planId = merlinHelper.insertPlan(missionModelId, merlinHelper.user.name()); + + // Update plan bounds + merlinHelper.updatePlanDuration(planId, "28:00:00"); + + // Get a handle on the revision + final var oldRevision = merlinHelper.getPlanRevision(planId); + + // Restore the automatically created snapshot + final var oldSnapshotId = getLatestSnapshot(planId); + restoreFromSnapshot(planId, oldSnapshotId); + + // The plan bounds should be restored + assertEquals("2020-01-01 00:00:00+00", merlinHelper.getPlanStartTime(planId)); + assertEquals("00:00:00", merlinHelper.getPlanDuration(planId)); + + // The plan's revision should have been updated + assertTrue(merlinHelper.getPlanRevision(planId) > oldRevision); + + // A new snapshot should have been created + final var newSnapshotId = getLatestSnapshot(planId); + assertNotEquals(oldSnapshotId, newSnapshotId); + + // Check the new snapshot's contents + final var snapshot = getSnapshotMetadata(newSnapshotId); + assertEquals("Plan Bound Adjustment", snapshot.snapshot_name); + assertEquals("Automatic snapshot made before adjusting plan bounds from " + + "[2020-01-01 00:00:00+00 - 2020-01-02 04:00:00+00] to " + + "[2020-01-01 00:00:00+00 - 2020-01-01 00:00:00+00]", snapshot.description); + assertEquals(planId, snapshot.plan_id); + assertEquals(missionModelId, snapshot.model_id); + assertEquals("2020-01-01 00:00:00+00", snapshot.planStartTime); + assertEquals("28:00:00", snapshot.planDuration); + assertEquals(oldRevision, snapshot.revision); + } } @Nested @@ -994,7 +1048,7 @@ void duplicateAttachesParentHistoryToChild() throws SQLException{ parentHistory.add(parentRes.getInt(1)); } - assertEquals(parentHistory.size(), numberOfSnapshots + 1); + assertEquals(numberOfSnapshots + 1, parentHistory.size()); final var childHistory = new ArrayList(); while (childRes.next()) { @@ -1006,16 +1060,9 @@ void duplicateAttachesParentHistoryToChild() throws SQLException{ } @Test - void duplicateNonexistentPlanFails() throws SQLException { - try { - duplicatePlan(1000, "Nonexistent Parent Duplicate"); - fail(); - } - catch(SQLException sqlEx) - { - if(!sqlEx.getMessage().contains("Plan 1000 does not exist.")) - throw sqlEx; - } + void duplicateNonexistentPlanFails() { + final var ex = assertThrows(SQLException.class, () -> duplicatePlan(1000, "Nonexistent Parent Duplicate")); + assertTrue(ex.getMessage().contains("Plan 1000 does not exist.")); } } @@ -1046,15 +1093,9 @@ void getPlanHistoryNoAncestors() throws SQLException { } @Test - void getPlanHistoryInvalidId() throws SQLException { - try { - getPlanHistory(-1); - fail(); - } - catch (SQLException sqlException) { - if (!sqlException.getMessage().contains("Plan ID -1 is not present in plan table.")) - throw sqlException; - } + void getPlanHistoryInvalidId() { + final var ex = assertThrows(SQLException.class, () -> getPlanHistory(-1)); + assertTrue(ex.getMessage().contains("Plan ID -1 is not present in plan table.")); } @Test @@ -1102,10 +1143,8 @@ void updateActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.updateActivityName(newName, activityId, planId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.updateActivityName(newName, activityId, planId)); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1130,10 +1169,8 @@ void deleteActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.deleteActivityDirective(planId, activityId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.deleteActivityDirective(planId, activityId)); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1154,10 +1191,8 @@ void insertActivityShouldFailOnLockedPlan() throws SQLException { try { lockPlan(planId); - merlinHelper.insertActivity(planId); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Plan " + planId + " is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.insertActivity(planId)); + assertTrue(ex.getMessage().contains("Plan " + planId + " is locked.")); } finally { unlockPlan(planId); } @@ -1183,10 +1218,8 @@ void beginReviewFailsOnLockedPlan() throws SQLException { try { lockPlan(planId); - beginMerge(mergeRequest); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin merge request. Plan to receive changes is locked.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRequest)); + assertTrue(ex.getMessage().contains("Cannot begin merge request. Plan to receive changes is locked."));; } finally { unlockPlan(planId); } @@ -1198,11 +1231,8 @@ void deletePlanFailsWhileLocked() throws SQLException { try { lockPlan(planId); - merlinHelper.deletePlan(planId); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot delete locked plan.")) - throw sqlEx; + final var ex = assertThrows(SQLException.class, () -> merlinHelper.deletePlan(planId)); + assertTrue(ex.getMessage().contains("Cannot delete locked plan.")); } finally { unlockPlan(planId); } @@ -1423,27 +1453,21 @@ void mergeBaseFailsForInvalidPlanIds() throws SQLException { final int snapshotId = createSnapshot(planId); try(final var statement = connection.createStatement()) { - statement.execute( + final var ex = assertThrows(SQLException.class, () -> statement.execute( //language=sql """ select merlin.get_merge_base(%d, -1); - """.formatted(planId)); - } - catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Snapshot ID "+-1 +" is not present in plan_snapshot table.")) - throw sqlEx; + """.formatted(planId))); + assertTrue(ex.getMessage().contains("Snapshot ID "+ -1 +" is not present in plan_snapshot table.")); } try(final var statement = connection.createStatement()) { - statement.execute( + final var ex = assertThrows(SQLException.class, () -> statement.execute( //language=sql """ select merlin.get_merge_base(-2, %d); - """.formatted(snapshotId)); - } - catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Snapshot ID "+-2 +" is not present in plan_snapshot table.")) - throw sqlEx; + """.formatted(snapshotId))); + assertTrue(ex.getMessage().contains("Plan ID "+ -2 +" is not present in plan_snapshot table.")); } } @@ -1511,23 +1535,11 @@ class MergeRequestTests{ void createRequestFailsForNonexistentPlans() throws SQLException { final int planId = merlinHelper.insertPlan(missionModelId); - try{ - createMergeRequest(planId, -1); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Plan supplying changes (Plan -1) does not exist.")) - throw sqEx; - } + final var exInvalidSupplying = assertThrows(SQLException.class, () -> createMergeRequest(planId, -1)); + assertTrue(exInvalidSupplying.getMessage().contains("Plan supplying changes (Plan -1) does not exist.")); - try{ - createMergeRequest(-1, planId); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Plan receiving changes (Plan -1) does not exist.")) - throw sqEx; - } + final var exInvalidReceiving = assertThrows(SQLException.class, () -> createMergeRequest(-1, planId)); + assertTrue(exInvalidReceiving.getMessage().contains("Plan receiving changes (Plan -1) does not exist.")); } @Test @@ -1538,39 +1550,53 @@ void createRequestFailsForUnrelatedPlans() throws SQLException { //Creating a snapshot so that the error comes from create_merge_request, not get_merge_base createSnapshot(plan1); - try{ - createMergeRequest(plan1, plan2); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Cannot create merge request between unrelated plans.")) - throw sqEx; - } + final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan1, plan2)); + assertTrue(ex.getMessage().contains("Cannot create merge request between unrelated plans.")); } @Test void createRequestFailsBetweenPlanAndSelf() throws SQLException { final int plan = merlinHelper.insertPlan(missionModelId); - try{ - createMergeRequest(plan, plan); - fail(); - } catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Cannot create a merge request between a plan and itself.")) - throw sqEx; - } + final var ex = assertThrows(SQLException.class, () -> createMergeRequest(plan, plan)); + assertTrue(ex.getMessage().contains("Cannot create a merge request between a plan and itself.")); } @Test - void withdrawFailsForNonexistentRequest() throws SQLException { - try{ - withdrawMergeRequest(-1); - fail(); - } - catch (SQLException sqEx){ - if(!sqEx.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")) - throw sqEx; - } + void withdrawFailsForNonexistentRequest() { + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); + assertTrue(ex.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")); + } + + /** + * If two plans have different bounds, a merge request cannot be created between them. + */ + @Test + void createRequestFailsBoundsDiffer() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Update the bounds of the parent plan + merlinHelper.updatePlanDuration(planId, "24:00:00"); + + // Merge request creation should fail + final var ex1 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); + assertTrue(ex1.getMessage().contains("Cannot create merge request between plans with different bounds.")); + + // Update the child so they have the same bounds + merlinHelper.updatePlanDuration(childId, "24:00:00"); + + // Merge request creation should succeed + assertDoesNotThrow(() -> createMergeRequest(planId, childId)); + + // Update the child so it has different bounds + merlinHelper.updatePlanDuration(childId, "48:00:00"); + + // Merge request creation should fail + final var ex2 = assertThrows(SQLException.class, () -> createMergeRequest(planId, childId)); + assertTrue(ex2.getMessage().contains("Cannot create merge request between plans with different bounds.")); } } @@ -1581,14 +1607,59 @@ void withdrawFailsForNonexistentRequest() throws SQLException { @Nested class BeginMergeTests { @Test - void beginMergeFailsOnInvalidRequestId() throws SQLException { - try{ - beginMerge(-1); - fail(); - }catch (SQLException sqlEx){ - if(!sqlEx.getMessage().contains("Request ID -1 is not present in merge_request table.")) - throw sqlEx; - } + void beginMergeFailsOnInvalidRequestId() { + final var ex = assertThrows(SQLException.class, () -> beginMerge(-1)); + assertTrue(ex.getMessage().contains("Request ID -1 is not present in merge_request table.")); + } + + /** + * If the plan receiving changes has its bounds changed between a merge request being made + * and the merge beginning, then the "begin_merge" method fails. + */ + @Test + void beginMergeReceivingBoundsChange() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Create a merge request + final var mergeRQId = createMergeRequest(planId, childId); + + // Update the plan bounds + merlinHelper.updatePlanDuration(planId, "24:00:00"); + + // Attempt to begin a merge request + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQId)); + + assertTrue(ex.getMessage().contains("Cannot begin merge request between plans with different bounds.")); + + unlockPlan(planId); + } + + /** + * If the plan supplying changes has its bounds changed between a merge request being made + * and the merge beginning, then the merge succeeds as normal. + * This is because the snapshot used in the merge request is unaffected by changes to the plan, + * including bounds changes. + */ + @Test + void beginMergeSupplyingBoundsChange() throws SQLException { + final int planId = merlinHelper.insertPlan(missionModelId); + final int childId = duplicatePlan(planId, "Child Plan"); + + merlinHelper.insertActivity(childId); + + // Create a merge request + final var mergeRQId = createMergeRequest(planId, childId); + + // Update the child plan's bounds + merlinHelper.updatePlanDuration(childId, "24:00:00"); + + // Attempt to begin a merge request. This should succeed + assertDoesNotThrow(() -> beginMerge(mergeRQId)); + + unlockPlan(planId); } @Test @@ -1625,14 +1696,9 @@ void beginMergeNoChangesThrowsError() throws SQLException { merlinHelper.insertActivity(planId); final int childPlan = duplicatePlan(planId, "Child"); - try { - beginMerge(createMergeRequest(planId,childPlan)); - fail(); - } catch (SQLException sqlex) { - if(!sqlex.getMessage().contains("Cannot begin merge. The contents of the two plans are identical.")){ - throw sqlex; - } - } + final var ex = assertThrows(SQLException.class, () -> beginMerge(createMergeRequest(planId,childPlan))); + assertTrue(ex.getMessage().contains("Cannot begin merge. The contents of the two plans are identical.")); + // Assert that the plan was not locked assertFalse(isPlanLocked(planId)); } @@ -2013,14 +2079,9 @@ void deleteDeleteIsExcludedFromStageAndConflict() throws SQLException { @Nested class CommitMergeTests{ @Test - void commitMergeFailsForNonexistentId() throws SQLException { - try { - commitMerge(-1); - fail(); - } catch (SQLException sqlex){ - if(!sqlex.getMessage().contains("Invalid merge request id -1.")) - throw sqlex; - } + void commitMergeFailsForNonexistentId() { + final var ex = assertThrows(SQLException.class, () -> commitMerge(-1)); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test @@ -2034,13 +2095,9 @@ void commitMergeFailsIfConflictsExist() throws SQLException { final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); - try{ - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlex){ - if(!sqlex.getMessage().contains("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.")) - throw sqlex; - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("There are unresolved conflicts in merge request "+mergeRQ+". Cannot commit merge.")); } @Test @@ -2456,36 +2513,21 @@ void ModifyUntouchedPersists() throws SQLException { @Nested class MergeStateMachineTests{ @Test - void cancelFailsForInvalidId() throws SQLException{ - try{ - cancelMerge(-1); - fail(); - } catch (SQLException sqlException) { - if(!sqlException.getMessage().contains("Invalid merge request id -1.")) - throw sqlException; - } + void cancelFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> cancelMerge(-1)); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test - void denyFailsForInvalidId() throws SQLException { - try{ - denyMerge(-1); - fail(); - } catch (SQLException sqlException) { - if(!sqlException.getMessage().contains("Invalid merge request id -1.")) - throw sqlException; - } + void denyFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> denyMerge(-1)); + assertTrue(ex.getMessage().contains("Invalid merge request id -1.")); } @Test - void withdrawFailsForInvalidId() throws SQLException { - try{ - withdrawMergeRequest(-1); - fail(); - } catch (SQLException sqlException){ - if(!sqlException.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")) - throw sqlException; - } + void withdrawFailsForInvalidId() { + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(-1)); + assertTrue(ex.getMessage().contains("Merge request -1 does not exist. Cannot withdraw request.")); } @Test @@ -2496,229 +2538,137 @@ void defaultStateOfMergeRequestIsPendingStatus() throws SQLException { assertEquals("pending", mergeRequest.status); } - @Test - void beginMergeOnlySucceedsOnPendingStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "accepted", "rejected", "in-progress"}) + void beginMergeFailsOnNonPendingStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - beginMerge(mergeRQ); - } catch (SQLException sqlEx){ - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "accepted"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "rejected"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - try { - beginMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "pending"); - beginMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> beginMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("Cannot begin request. Merge request "+mergeRQ+" is not in pending state.")); } @Test - void withdrawOnlySucceedsOnPendingOrWithdrawnStatus() throws SQLException { + void beginMergeSucceedsOnPendingStatus() throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); setMergeRequestStatus(mergeRQ, "pending"); - withdrawMergeRequest(mergeRQ); - - setMergeRequestStatus(mergeRQ, "withdrawn"); - withdrawMergeRequest(mergeRQ); + assertDoesNotThrow(() -> beginMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"accepted", "rejected", "in-progress"}) + void withdrawFailsAcceptedRejectedInProgress(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - try { - withdrawMergeRequest(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot withdraw request.")) - throw sqlEx; - } + final var ex = assertThrows(SQLException.class, () -> withdrawMergeRequest(mergeRQ)); + assertTrue(ex.getMessage().contains("Cannot withdraw request.")); } - @Test - void cancelOnlySucceedsOnInProgressOrPendingStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"accepted", "rejected", "in-progress"}) + void withdrawSucceedsPendingOrWithdrawnStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); - beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "pending"); - cancelMerge(mergeRQ); + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + assertDoesNotThrow(() -> withdrawMergeRequest(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "accepted", "rejected"}) + void cancelFailsWithdrawnAcceptedRejected(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - cancelMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot cancel merge.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - cancelMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> cancelMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("Cannot cancel merge.")); } - @Test - void denyOnlySucceedsOnInProgressStatus() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"pending", "in-progress"}) + void cancelSucceedsInProgressOrPendingStatus(String status) throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "pending"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } - - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); + assertDoesNotThrow(() -> cancelMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "accepted"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "pending", "accepted", "rejected"}) + void denyFailsNonInProgressStatus(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "rejected"); - try { - denyMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot reject merge not in progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "in-progress"); - denyMerge(mergeRQ); + final var ex = assertThrows(SQLException.class, () -> denyMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("Cannot reject merge not in progress.")); } @Test - void commitOnlySucceedsOnInProgressStatus() throws SQLException { + void denySucceedsInProgressStatus() throws SQLException { final int basePlan = merlinHelper.insertPlan(missionModelId); final int childPlan = duplicatePlan(basePlan, "Child"); merlinHelper.insertActivity(childPlan); final int mergeRQ = createMergeRequest(basePlan, childPlan); beginMerge(mergeRQ); + setMergeRequestStatus(mergeRQ, "in-progress"); + assertDoesNotThrow(() -> denyMerge(mergeRQ)); + } - setMergeRequestStatus(mergeRQ, "pending"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + @ParameterizedTest + @ValueSource(strings = {"withdrawn", "pending", "accepted", "rejected"}) + void commitFailsNonInProgressStatus(String status) throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); - setMergeRequestStatus(mergeRQ, "withdrawn"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + setMergeRequestStatus(mergeRQ, status); - setMergeRequestStatus(mergeRQ, "accepted"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("Cannot reject merge not in progress.")); + } - setMergeRequestStatus(mergeRQ, "rejected"); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException sqlEx) { - if (!sqlEx.getMessage().contains("Cannot commit a merge request that is not in-progress.")) - throw sqlEx; - } + @Test + void commitOnlySucceedsOnInProgressStatus() throws SQLException { + final int basePlan = merlinHelper.insertPlan(missionModelId); + final int childPlan = duplicatePlan(basePlan, "Child"); + merlinHelper.insertActivity(childPlan); + final int mergeRQ = createMergeRequest(basePlan, childPlan); + beginMerge(mergeRQ); setMergeRequestStatus(mergeRQ, "in-progress"); - commitMerge(mergeRQ); + assertDoesNotThrow(() -> commitMerge(mergeRQ)); } /** @@ -2901,14 +2851,9 @@ void cantMergeCycle() throws SQLException{ // Merge fails as it would establish B -> A -> B cycle final int mergeRQ = createMergeRequest(planId, childPlan); beginMerge(mergeRQ); - try { - commitMerge(mergeRQ); - fail(); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Cycle detected. Cannot apply changes.")){ - throw ex; - } - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("Cycle detected. Cannot apply changes.")); } @Test @@ -2925,15 +2870,9 @@ void anchorMustBeInTargetPlanAtEndOfMerge() throws SQLException{ final int mergeRQ = createMergeRequest(planId, childPlan); beginMerge(mergeRQ); - try{ - commitMerge(mergeRQ); - fail(); - } catch (SQLException ex){ - if(!ex.getMessage().contains( - "insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")){ - throw ex; - } - } + + final var ex = assertThrows(SQLException.class, () -> commitMerge(mergeRQ)); + assertTrue(ex.getMessage().contains("insert or update on table \"activity_directive\" violates foreign key constraint \"anchor_in_plan\"")); } @Test @@ -3426,20 +3365,12 @@ void tagsCannotBeDeletedMidMerge() throws SQLException { final int mergeRQ = createMergeRequest(planId, branchId); beginMerge(mergeRQ); - try { - tagsHelper.deleteTag(activityTagId); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Plan "+planId +" is locked.")){ - throw ex; - } - } - try { - tagsHelper.deleteTag(snapshotTagId); - } catch (SQLException ex) { - if(!ex.getMessage().contains("Cannot delete. Snapshot is in use in an active merge review.")){ - throw ex; - } - } + final var exActivityTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(activityTagId)); + assertTrue(exActivityTagDelete.getMessage().contains("Plan "+planId +" is locked.")); + + final var exSnapshotTagDelete = assertThrows(SQLException.class, () -> tagsHelper.deleteTag(snapshotTagId)); + assertTrue(exSnapshotTagDelete.getMessage().contains("Cannot delete. Snapshot is in use in an active merge review.")); + assertDoesNotThrow(()->tagsHelper.deleteTag(unrelatedTagId)); unlockPlan(planId); diff --git a/deployment/hasura/metadata/databases/tables/merlin/plan.yaml b/deployment/hasura/metadata/databases/tables/merlin/plan.yaml index a29f047350..8b78d91c46 100644 --- a/deployment/hasura/metadata/databases/tables/merlin/plan.yaml +++ b/deployment/hasura/metadata/databases/tables/merlin/plan.yaml @@ -107,7 +107,7 @@ update_permissions: updated_by: "x-hasura-user-id" - role: user permission: - columns: [name, owner, description] + columns: [name, owner, duration, description, start_time] filter: {"owner":{"_eq":"X-Hasura-User-Id"}} set: updated_by: "x-hasura-user-id" diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql new file mode 100644 index 0000000000..708723413c --- /dev/null +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/down.sql @@ -0,0 +1,583 @@ +-- Restore plan merge functions +create or replace procedure merlin.begin_merge(_merge_request_id integer, review_username text) + language plpgsql as $$ +declare + validate_id integer; + validate_status merlin.merge_request_status; + validate_non_no_op_status merlin.activity_change_type; + snapshot_id_supplying integer; + plan_id_receiving integer; + merge_base_id integer; +begin + -- validate id and status + select id, status + from merlin.merge_request + where _merge_request_id = id + into validate_id, validate_status; + + if validate_id is null then + raise exception 'Request ID % is not present in merge_request table.', _merge_request_id; + end if; + + if validate_status != 'pending' then + raise exception 'Cannot begin request. Merge request % is not in pending state.', _merge_request_id; + end if; + + -- select from merge-request the snapshot_sc (s_sc) and plan_rc (p_rc) ids + select plan_id_receiving_changes, snapshot_id_supplying_changes + from merlin.merge_request + where id = _merge_request_id + into plan_id_receiving, snapshot_id_supplying; + + -- ensure the plan receiving changes isn't locked + if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then + raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; + end if; + + -- lock plan_rc + update merlin.plan + set is_locked = true + where plan.id = plan_id_receiving; + + -- get merge base (mb) + select merlin.get_merge_base(plan_id_receiving, snapshot_id_supplying) + into merge_base_id; + + -- update the status to "in progress" + update merlin.merge_request + set status = 'in-progress', + merge_base_snapshot_id = merge_base_id, + reviewer_username = review_username + where id = _merge_request_id; + + + -- perform diff between mb and s_sc (s_diff) + -- delete is B minus A on key + -- add is A minus B on key + -- A intersect B is no op + -- A minus B on everything except everything currently in the table is modify + create temp table supplying_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'none' + from( + select psa.id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'modify' + from( + select id as activity_id from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id or snapshot_id = snapshot_id_supplying + except + select activity_id from supplying_diff) a; + + -- perform diff between mb and p_rc (r_diff) + create temp table receiving_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'none' + from( + select id as activity_id, name, tags.tag_ids_activity_snapshot(id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_directive(id, plan_id_receiving), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.activity_directive ad + where ad.plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'modify' + from ( + (select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + union + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) + except + select activity_id + from receiving_diff) a; + + + -- perform diff between s_diff and r_diff + -- upload the non-conflicts into merge_staging_area + -- upload conflict into conflicting_activities + create temp table diff_diff( + activity_id integer, + change_type_supplying merlin.activity_change_type not null, + change_type_receiving merlin.activity_change_type not null + ); + + -- this is going to require us to do the "none" operation again on the remaining modifies + -- but otherwise we can just dump the 'adds' and 'none' into the merge staging area table + + -- 'delete' against a 'delete' does not enter the merge staging area table + -- receiving 'delete' against supplying 'none' does not enter the merge staging area table + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- 'adds' can go directly into the merge staging area table + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(s_diff.activity_id, psa.snapshot_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + from supplying_diff as s_diff + join merlin.plan_snapshot_activities psa + on s_diff.activity_id = psa.id + where snapshot_id = snapshot_id_supplying and change_type = 'add' + union + -- an 'add' between the receiving plan and merge base is actually a 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(r_diff.activity_id, ad.plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none'::merlin.activity_change_type + from receiving_diff as r_diff + join merlin.activity_directive ad + on r_diff.activity_id = ad.id + where plan_id = plan_id_receiving and change_type = 'add'; + + -- put the rest in diff_diff + insert into diff_diff (activity_id, change_type_supplying, change_type_receiving) + select activity_id, supplying_diff.change_type as change_type_supplying, receiving_diff.change_type as change_type_receiving + from receiving_diff + join supplying_diff using (activity_id) + where receiving_diff.change_type != 'add' or supplying_diff.change_type != 'add'; + + -- ...except for that which is not recorded + delete from diff_diff + where (change_type_receiving = 'delete' and change_type_supplying = 'delete') + or (change_type_receiving = 'delete' and change_type_supplying = 'none'); + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- receiving 'none' and 'modify' against 'none' in the supplying side go into the merge staging area as 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none' + from diff_diff + join merlin.activity_directive + on activity_id=id + where plan_id = plan_id_receiving + and change_type_supplying = 'none' + and (change_type_receiving = 'modify' or change_type_receiving = 'none') + union + -- supplying 'modify' against receiving 'none' go into the merge staging area as 'modify' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(diff_diff.activity_id, snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.plan_snapshot_activities p + on diff_diff.activity_id = p.id + where snapshot_id = snapshot_id_supplying + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') + union + -- supplying 'delete' against receiving 'none' go into the merge staging area as 'delete' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.activity_directive p + on diff_diff.activity_id = p.id + where plan_id = plan_id_receiving + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); + + -- 'modify' against a 'modify' must be checked for equality first. + with false_modify as ( + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, psa.snapshot_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + join diff_diff dd + on dd.activity_id = psa.id + where psa.snapshot_id = snapshot_id_supplying + and (dd.change_type_receiving = 'modify' and dd.change_type_supplying = 'modify') + intersect + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, ad.plan_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from diff_diff dd + join merlin.activity_directive ad + on dd.activity_id = ad.id + where ad.plan_id = plan_id_receiving + and (dd.change_type_supplying = 'modify' and dd.change_type_receiving = 'modify')) + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type) + select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, + ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, + ad.anchor_id, ad.anchored_to_start, 'none' + from false_modify fm + left join merlin.activity_directive ad + on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); + + -- 'modify' against 'delete' and inequal 'modify' against 'modify' goes into conflict table (aka everything left in diff_diff) + insert into merlin.conflicting_activities (merge_request_id, activity_id, change_type_supplying, change_type_receiving) + select begin_merge._merge_request_id, activity_id, change_type_supplying, change_type_receiving + from (select begin_merge._merge_request_id, activity_id + from diff_diff + except + select msa.merge_request_id, activity_id + from merlin.merge_staging_area msa) a + join diff_diff using (activity_id); + + -- Fail if there are no differences between the snapshot and the plan getting merged + validate_non_no_op_status := null; + select change_type_receiving + from merlin.conflicting_activities + where merge_request_id = _merge_request_id + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + select change_type + from merlin.merge_staging_area msa + where merge_request_id = _merge_request_id + and msa.change_type != 'none' + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + raise exception 'Cannot begin merge. The contents of the two plans are identical.'; + end if; + end if; + + + -- clean up + drop table supplying_diff; + drop table receiving_diff; + drop table diff_diff; +end +$$; + +create or replace function merlin.create_merge_request(plan_id_supplying integer, plan_id_receiving integer, request_username text) + returns integer + language plpgsql as $$ +declare + merge_base_snapshot_id integer; + validate_planIds integer; + supplying_snapshot_id integer; + merge_request_id integer; + model_id_receiving integer; + model_id_supplying integer; +begin + if plan_id_receiving = plan_id_supplying then + raise exception 'Cannot create a merge request between a plan and itself.'; + end if; + select id from merlin.plan where plan.id = plan_id_receiving into validate_planIds; + if validate_planIds is null then + raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; + end if; + select id from merlin.plan where plan.id = plan_id_supplying into validate_planIds; + if validate_planIds is null then + raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; + end if; + + select merlin.create_snapshot(plan_id_supplying) into supplying_snapshot_id; + + select merlin.get_merge_base(plan_id_receiving, supplying_snapshot_id) into merge_base_snapshot_id; + if merge_base_snapshot_id is null then + raise exception 'Cannot create merge request between unrelated plans.'; + end if; + + select model_id from merlin.plan where plan.id = plan_id_receiving into model_id_receiving; + select model_id from merlin.plan where plan.id = plan_id_supplying into model_id_supplying; + if model_id_receiving is distinct from model_id_supplying then + raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; + end if; + + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) + values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) + returning id into merge_request_id; + return merge_request_id; +end +$$; + +-- Restore "Update Plan Revision on Directive Change" trigger behavior +create or replace trigger increment_plan_revision_on_directive_update_trigger + after update on merlin.activity_directive + for each row +execute function merlin.increment_plan_revision_on_directive_update(); + +-- Drop new triggers +drop trigger cascade_plan_bounds_on_update on merlin.plan; +drop function merlin.cascade_plan_bounds_update(); + +-- Restore Snapshot Creation and Restoration Functions +create or replace procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id integer) + language plpgsql as $$ +declare + _snapshot_name text; + _plan_name text; + _model_id integer; +begin + -- Input Validation + select name from merlin.plan where id = _plan_id into _plan_name; + if _plan_name is null then + raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then + raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then + select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; + if _snapshot_name is not null then + raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', + _snapshot_name, _snapshot_id, _plan_name, _plan_id; + else + raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', + _snapshot_id, _plan_name, _plan_id; + end if; + end if; + select model_id from merlin.plan_snapshot where snapshot_id = _snapshot_id into _model_id; + if not exists(select from merlin.mission_model m where m.id = _model_id) then + raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; + end if; + + -- Catch Plan_Locked + call merlin.plan_locked_exception(_plan_id); + + -- Update model_id of the plan + update merlin.plan + set model_id = _model_id + where id = _plan_id; + + -- Record the Union of Activities in Plan and Snapshot + -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) + create temp table diff( + activity_id integer, + in_snapshot boolean not null + ); + insert into diff(activity_id, in_snapshot) + select id as activity_id, true + from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; + + insert into diff (activity_id, in_snapshot) + select activity_id, false + from( + select id as activity_id + from merlin.activity_directive + where plan_id = _plan_id + except + select activity_id + from diff) a; + + -- Remove any added activities + delete from merlin.activity_directive ad + using diff d + where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) + and d.in_snapshot is false; + + -- Upsert the rest + insert into merlin.activity_directive ( + id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_at, last_modified_by, + start_offset, type, arguments, last_modified_arguments_at, metadata, + anchor_id, anchored_to_start) + select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, + psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, + psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, + psa.anchor_id, psa.anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = _snapshot_id + on conflict (id, plan_id) do update + -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() + set name = excluded.name, + source_scheduling_goal_id = excluded.source_scheduling_goal_id, + source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, + created_at = excluded.created_at, + created_by = excluded.created_by, + last_modified_by = excluded.last_modified_by, + start_offset = excluded.start_offset, + type = excluded.type, + arguments = excluded.arguments, + metadata = excluded.metadata, + anchor_id = excluded.anchor_id, + anchored_to_start = excluded.anchored_to_start; + + -- Tags + delete from tags.activity_directive_tags adt + using diff d + where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); + + insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) + select sat.directive_id, _plan_id, sat.tag_id + from tags.snapshot_activity_tags sat + where sat.snapshot_id = _snapshot_id + on conflict (directive_id, plan_id, tag_id) do nothing; + + -- Presets + delete from merlin.preset_to_directive + where plan_id = _plan_id; + insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) + select pts.preset_id, pts.activity_id, _plan_id + from merlin.preset_to_snapshot_directive pts + where pts.snapshot_id = _snapshot_id + on conflict (activity_id, plan_id) + do update set preset_id = excluded.preset_id; + + -- Clean up + drop table diff; +end +$$; + +create or replace function merlin.create_snapshot(_plan_id integer, _snapshot_name text, _description text, _user text) + returns integer -- snapshot id inserted into the table + language plpgsql as $$ +declare + validate_plan_id integer; + inserted_snapshot_id integer; +begin + select id from merlin.plan where plan.id = _plan_id into validate_plan_id; + if validate_plan_id is null then + raise exception 'Plan % does not exist.', _plan_id; + end if; + + insert into merlin.plan_snapshot(plan_id, model_id, revision, snapshot_name, description, taken_by) + select id, model_id, revision, _snapshot_name, _description, _user + from merlin.plan where id = _plan_id + returning snapshot_id into inserted_snapshot_id; + insert into merlin.plan_snapshot_activities( + snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) + select + inserted_snapshot_id, -- this is the snapshot id + id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start + from merlin.activity_directive where activity_directive.plan_id = _plan_id; + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) + select ptd.preset_id, ptd.activity_id, inserted_snapshot_id + from merlin.preset_to_directive ptd + where ptd.plan_id = _plan_id; + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) + select inserted_snapshot_id, directive_id, tag_id + from tags.activity_directive_tags adt + where adt.plan_id = _plan_id; + + --all snapshots in plan_latest_snapshot for plan plan_id become the parent of the current snapshot + insert into merlin.plan_snapshot_parent(snapshot_id, parent_snapshot_id) + select inserted_snapshot_id, snapshot_id + from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + + --remove all of those entries from plan_latest_snapshot and add this new snapshot. + delete from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + insert into merlin.plan_latest_snapshot(plan_id, snapshot_id) values (_plan_id, inserted_snapshot_id); + + return inserted_snapshot_id; +end; +$$; + +-- Drop new columns +alter table merlin.plan_snapshot + drop column plan_start_time, + drop column plan_duration; + +-- Data Migration: Prepare to restore snapshot name uniqueness constraint +-- First, add 'at TIMESTAMP' to any duplicate names +update merlin.plan_snapshot ps +set snapshot_name = snapshot_name || ' at ' || taken_at +from ( + select + snapshot_id, + row_number() over (partition by (snapshot_name, plan_id)) - 1 as row + from merlin.plan_snapshot +) as ir +where ps.snapshot_id = ir.snapshot_id +and ir.row > 0; + +-- Then, deduplicate any duplicate names +do $$ + begin + -- While there are duplicate names in the snapshots table... + while exists( + select from merlin.plan_snapshot + group by snapshot_name, plan_id + having count(snapshot_name) > 1 + ) loop + -- ...deduplicate them + update merlin.plan_snapshot ps + set snapshot_name = snapshot_name || '(' || ir.row || ')' + from ( + select snapshot_id, + row_number() over (partition by snapshot_name, plan_id) - 1 as row + from merlin.plan_snapshot + ) as ir + where ps.snapshot_id = ir.snapshot_id + and ir.row > 0; + end loop; + end +$$; + +-- Restore uniqueness constraint +alter table merlin.plan_snapshot +add constraint snapshot_name_unique_per_plan + unique (plan_id, snapshot_name); + +call migrations.mark_migration_rolled_back(35); diff --git a/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql new file mode 100644 index 0000000000..d0faaf69d1 --- /dev/null +++ b/deployment/hasura/migrations/Aerie/35_change_plan_bounds/up.sql @@ -0,0 +1,660 @@ +-- Drop snapshot name uniqueness constraint +alter table merlin.plan_snapshot + drop constraint snapshot_name_unique_per_plan; + +-- Update Plan Snapshot to include plan bounds +alter table merlin.plan_snapshot +add column plan_start_time timestamptz, +add column plan_duration interval; + +comment on column merlin.plan_snapshot.plan_start_time is e'' + 'The start time of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_duration is e'' + 'The duration of the plan at the time the snapshot was taken.'; + +-- Data Migration: fill in default info for these columns +update merlin.plan_snapshot +set plan_start_time = p.start_time, + plan_duration = p.duration +from merlin.plan p +where p.id = plan_id; + +-- Add not null argument to new columns +alter table merlin.plan_snapshot +alter column plan_start_time set not null, +alter column plan_duration set not null; + +-- Update Create and Restore snapshot functions +create or replace function merlin.create_snapshot(_plan_id integer, _snapshot_name text, _description text, _user text) + returns integer -- snapshot id inserted into the table + language plpgsql as $$ + declare + validate_plan_id integer; + inserted_snapshot_id integer; +begin + select id from merlin.plan where plan.id = _plan_id into validate_plan_id; + if validate_plan_id is null then + raise exception 'Plan % does not exist.', _plan_id; + end if; + + insert into merlin.plan_snapshot(plan_id, model_id, revision, plan_start_time, plan_duration, + snapshot_name, description, taken_by) + select id, model_id, revision, start_time, duration, + _snapshot_name, _description, _user + from merlin.plan where id = _plan_id + returning snapshot_id into inserted_snapshot_id; + + insert into merlin.plan_snapshot_activities( + snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start) + select + inserted_snapshot_id, -- this is the snapshot id + id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, -- these are the rest of the data for an activity row + last_modified_at, last_modified_by, start_offset, type, + arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start + from merlin.activity_directive where activity_directive.plan_id = _plan_id; + + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) + select ptd.preset_id, ptd.activity_id, inserted_snapshot_id + from merlin.preset_to_directive ptd + where ptd.plan_id = _plan_id; + + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) + select inserted_snapshot_id, directive_id, tag_id + from tags.activity_directive_tags adt + where adt.plan_id = _plan_id; + + --all snapshots in plan_latest_snapshot for plan plan_id become the parent of the current snapshot + insert into merlin.plan_snapshot_parent(snapshot_id, parent_snapshot_id) + select inserted_snapshot_id, snapshot_id + from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + + --remove all of those entries from plan_latest_snapshot and add this new snapshot. + delete from merlin.plan_latest_snapshot where plan_latest_snapshot.plan_id = _plan_id; + insert into merlin.plan_latest_snapshot(plan_id, snapshot_id) values (_plan_id, inserted_snapshot_id); + + return inserted_snapshot_id; + end; +$$; + +create or replace procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id integer) + language plpgsql as $$ + declare + _snapshot_name text; + _plan_name text; + _model_id integer; + _plan_start_time timestamptz; + _plan_duration interval; + begin + -- Input Validation + select name from merlin.plan where id = _plan_id into _plan_name; + if _plan_name is null then + raise exception 'Cannot Restore: Plan with ID % does not exist.', _plan_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where snapshot_id = _snapshot_id) then + raise exception 'Cannot Restore: Snapshot with ID % does not exist.', _snapshot_id; + end if; + if not exists(select snapshot_id from merlin.plan_snapshot where _snapshot_id = snapshot_id and _plan_id = plan_id ) then + select snapshot_name from merlin.plan_snapshot where snapshot_id = _snapshot_id into _snapshot_name; + if _snapshot_name is not null then + raise exception 'Cannot Restore: Snapshot ''%'' (ID %) is not a snapshot of Plan ''%'' (ID %)', + _snapshot_name, _snapshot_id, _plan_name, _plan_id; + else + raise exception 'Cannot Restore: Snapshot % is not a snapshot of Plan ''%'' (ID %)', + _snapshot_id, _plan_name, _plan_id; + end if; + end if; + + select model_id, plan_start_time, plan_duration + from merlin.plan_snapshot + where snapshot_id = _snapshot_id + into _model_id, _plan_start_time, _plan_duration; + if not exists(select from merlin.mission_model m where m.id = _model_id) then + raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; + end if; + + -- Catch Plan_Locked + call merlin.plan_locked_exception(_plan_id); + + -- Update model_id and bounds of the plan + update merlin.plan + set model_id = _model_id, + start_time = _plan_start_time, + duration = _plan_duration + where id = _plan_id; + + -- Record the Union of Activities in Plan and Snapshot + -- and note which ones have been added since the Snapshot was taken (in_snapshot = false) + create temp table diff( + activity_id integer, + in_snapshot boolean not null + ); + insert into diff(activity_id, in_snapshot) + select id as activity_id, true + from merlin.plan_snapshot_activities where snapshot_id = _snapshot_id; + + insert into diff (activity_id, in_snapshot) + select activity_id, false + from( + select id as activity_id + from merlin.activity_directive + where plan_id = _plan_id + except + select activity_id + from diff) a; + + -- Remove any added activities + delete from merlin.activity_directive ad + using diff d + where (ad.id, ad.plan_id) = (d.activity_id, _plan_id) + and d.in_snapshot is false; + + -- Upsert the rest + insert into merlin.activity_directive ( + id, plan_id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_at, last_modified_by, + start_offset, type, arguments, last_modified_arguments_at, metadata, + anchor_id, anchored_to_start) + select psa.id, _plan_id, psa.name, psa.source_scheduling_goal_id, psa.source_scheduling_goal_invocation_id, + psa.created_at, psa.created_by, psa.last_modified_at, psa.last_modified_by, + psa.start_offset, psa.type, psa.arguments, psa.last_modified_arguments_at, psa.metadata, + psa.anchor_id, psa.anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = _snapshot_id + on conflict (id, plan_id) do update + -- 'last_modified_at' and 'last_modified_arguments_at' are skipped during update, as triggers will overwrite them to now() + set name = excluded.name, + source_scheduling_goal_id = excluded.source_scheduling_goal_id, + source_scheduling_goal_invocation_id = excluded.source_scheduling_goal_invocation_id, + created_at = excluded.created_at, + created_by = excluded.created_by, + last_modified_by = excluded.last_modified_by, + start_offset = excluded.start_offset, + type = excluded.type, + arguments = excluded.arguments, + metadata = excluded.metadata, + anchor_id = excluded.anchor_id, + anchored_to_start = excluded.anchored_to_start; + + -- Tags + delete from tags.activity_directive_tags adt + using diff d + where (adt.directive_id, adt.plan_id) = (d.activity_id, _plan_id); + + insert into tags.activity_directive_tags(directive_id, plan_id, tag_id) + select sat.directive_id, _plan_id, sat.tag_id + from tags.snapshot_activity_tags sat + where sat.snapshot_id = _snapshot_id + on conflict (directive_id, plan_id, tag_id) do nothing; + + -- Presets + delete from merlin.preset_to_directive + where plan_id = _plan_id; + insert into merlin.preset_to_directive(preset_id, activity_id, plan_id) + select pts.preset_id, pts.activity_id, _plan_id + from merlin.preset_to_snapshot_directive pts + where pts.snapshot_id = _snapshot_id + on conflict (activity_id, plan_id) + do update set preset_id = excluded.preset_id; + + -- Clean up + drop table diff; + end +$$; + +-- Create trigger to create snapshot/cascade plan bounds changes +create function merlin.cascade_plan_bounds_update() + returns trigger + language plpgsql as $$ +begin + -- prevent adjustment if the plan is locked + if old.is_locked then + raise exception 'Cannot adjust bounds of locked plan.'; + end if; + + -- Take a backup snapshot + perform merlin.create_snapshot( + old.id, + 'Plan Bound Adjustment', + 'Automatic snapshot made before adjusting plan bounds from ' || + '['|| old.start_time ||' - '|| old.start_time + old.duration || '] to ' || + '[' || new.start_time || ' - ' || new.start_time + new.duration || ']', + null); + + -- Update activities that are anchored to the plan bounds + update merlin.activity_directive ad + set start_offset = start_offset + (new.start_time - old.start_time) + where anchor_id is null + and anchored_to_start -- anchored to plan start + and ad.plan_id = old.id; + + update merlin.activity_directive ad + set start_offset = start_offset + (new.duration - old.duration) + where anchor_id is null + and not anchored_to_start -- anchored to plan end + and ad.plan_id = old.id; + + -- Update associated dataset offsets (simulation and plan) + update merlin.simulation_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + from merlin.simulation sim_spec + where simulation_id = sim_spec.id + and sim_spec.plan_id = old.id; + + update merlin.plan_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + where plan_id = old.id; + + return new; +end; +$$; + +create trigger cascade_plan_bounds_on_update + before update on merlin.plan + for each row + when (old.start_time is distinct from new.start_time or old.duration is distinct from new.duration) +execute function merlin.cascade_plan_bounds_update(); + +-- Prevent "Update Plan Revision on Directive Change" from firing during other triggers +create or replace trigger increment_plan_revision_on_directive_update_trigger + after update on merlin.activity_directive + for each row + when (pg_trigger_depth() < 1) +execute function merlin.increment_plan_revision_on_directive_update(); + +-- Update Plan Merge Functions to block merging plans with different bounds +create or replace function merlin.create_merge_request(plan_id_supplying integer, plan_id_receiving integer, request_username text) + returns integer + language plpgsql as $$ +declare + merge_base_snapshot_id integer; + validate_planIds integer; + supplying_snapshot_id integer; + merge_request_id integer; + model_id_receiving integer; + model_id_supplying integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; +begin + if plan_id_receiving = plan_id_supplying then + raise exception 'Cannot create a merge request between a plan and itself.'; + end if; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into validate_planIds, model_id_receiving, start_time_receiving, duration_receiving; + if validate_planIds is null then + raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; + end if; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_supplying + into validate_planIds, model_id_supplying, start_time_supplying, duration_supplying; + if validate_planIds is null then + raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; + end if; + + select merlin.create_snapshot(plan_id_supplying) into supplying_snapshot_id; + + select merlin.get_merge_base(plan_id_receiving, supplying_snapshot_id) into merge_base_snapshot_id; + if merge_base_snapshot_id is null then + raise exception 'Cannot create merge request between unrelated plans.'; + end if; + + if model_id_receiving is distinct from model_id_supplying then + raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; + end if; + + if (start_time_receiving is distinct from start_time_supplying) or + (duration_receiving is distinct from duration_supplying) then + raise exception 'Cannot create merge request between plans with different bounds.'; + end if; + + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) + values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) + returning id into merge_request_id; + return merge_request_id; +end +$$; + +create or replace procedure merlin.begin_merge(_merge_request_id integer, review_username text) + language plpgsql as $$ + declare + validate_id integer; + validate_status merlin.merge_request_status; + validate_non_no_op_status merlin.activity_change_type; + snapshot_id_supplying integer; + plan_id_receiving integer; + merge_base_id integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; +begin + -- validate id and status + select id, status + from merlin.merge_request + where _merge_request_id = id + into validate_id, validate_status; + + if validate_id is null then + raise exception 'Request ID % is not present in merge_request table.', _merge_request_id; + end if; + + if validate_status != 'pending' then + raise exception 'Cannot begin request. Merge request % is not in pending state.', _merge_request_id; + end if; + + -- select from merge-request the snapshot_sc (s_sc) and plan_rc (p_rc) ids + select plan_id_receiving_changes, snapshot_id_supplying_changes + from merlin.merge_request + where id = _merge_request_id + into plan_id_receiving, snapshot_id_supplying; + + -- ensure that the plans cover the same boundaries + select start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into start_time_receiving, duration_receiving; + + select plan_start_time, plan_duration + from merlin.plan_snapshot ps + where ps.snapshot_id = snapshot_id_supplying + into start_time_supplying, duration_supplying; + + if start_time_receiving is distinct from start_time_supplying or + duration_receiving is distinct from duration_supplying then + raise exception 'Cannot begin merge request between plans with different bounds.'; + end if; + + -- ensure the plan receiving changes isn't locked + if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then + raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; + end if; + + -- lock plan_rc + update merlin.plan + set is_locked = true + where plan.id = plan_id_receiving; + + -- get merge base (mb) + select merlin.get_merge_base(plan_id_receiving, snapshot_id_supplying) + into merge_base_id; + + -- update the status to "in progress" + update merlin.merge_request + set status = 'in-progress', + merge_base_snapshot_id = merge_base_id, + reviewer_username = review_username + where id = _merge_request_id; + + -- perform diff between mb and s_sc (s_diff) + -- delete is B minus A on key + -- add is A minus B on key + -- A intersect B is no op + -- A minus B on everything except everything currently in the table is modify + create temp table supplying_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = snapshot_id_supplying + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'none' + from( + select psa.id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_snapshot(psa.id, snapshot_id_supplying), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = snapshot_id_supplying) a; + + insert into supplying_diff (activity_id, change_type) + select activity_id, 'modify' + from( + select id as activity_id from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id or snapshot_id = snapshot_id_supplying + except + select activity_id from supplying_diff) a; + + -- perform diff between mb and p_rc (r_diff) + create temp table receiving_diff( + activity_id integer, + change_type merlin.activity_change_type not null + ); + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'delete' + from( + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + except + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'add' + from( + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving + except + select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'none' + from( + select id as activity_id, name, tags.tag_ids_activity_snapshot(id, merge_base_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + where psa.snapshot_id = merge_base_id + intersect + select id as activity_id, name, tags.tag_ids_activity_directive(id, plan_id_receiving), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, + metadata, anchor_id, anchored_to_start + from merlin.activity_directive ad + where ad.plan_id = plan_id_receiving) a; + + insert into receiving_diff (activity_id, change_type) + select activity_id, 'modify' + from ( + (select id as activity_id + from merlin.plan_snapshot_activities + where snapshot_id = merge_base_id + union + select id as activity_id + from merlin.activity_directive + where plan_id = plan_id_receiving) + except + select activity_id + from receiving_diff) a; + + + -- perform diff between s_diff and r_diff + -- upload the non-conflicts into merge_staging_area + -- upload conflict into conflicting_activities + create temp table diff_diff( + activity_id integer, + change_type_supplying merlin.activity_change_type not null, + change_type_receiving merlin.activity_change_type not null + ); + + -- this is going to require us to do the "none" operation again on the remaining modifies + -- but otherwise we can just dump the 'adds' and 'none' into the merge staging area table + + -- 'delete' against a 'delete' does not enter the merge staging area table + -- receiving 'delete' against supplying 'none' does not enter the merge staging area table + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- 'adds' can go directly into the merge staging area table + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(s_diff.activity_id, psa.snapshot_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + from supplying_diff as s_diff + join merlin.plan_snapshot_activities psa + on s_diff.activity_id = psa.id + where snapshot_id = snapshot_id_supplying and change_type = 'add' + union + -- an 'add' between the receiving plan and merge base is actually a 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(r_diff.activity_id, ad.plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none'::merlin.activity_change_type + from receiving_diff as r_diff + join merlin.activity_directive ad + on r_diff.activity_id = ad.id + where plan_id = plan_id_receiving and change_type = 'add'; + + -- put the rest in diff_diff + insert into diff_diff (activity_id, change_type_supplying, change_type_receiving) + select activity_id, supplying_diff.change_type as change_type_supplying, receiving_diff.change_type as change_type_receiving + from receiving_diff + join supplying_diff using (activity_id) + where receiving_diff.change_type != 'add' or supplying_diff.change_type != 'add'; + + -- ...except for that which is not recorded + delete from diff_diff + where (change_type_receiving = 'delete' and change_type_supplying = 'delete') + or (change_type_receiving = 'delete' and change_type_supplying = 'none'); + + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type + ) + -- receiving 'none' and 'modify' against 'none' in the supplying side go into the merge staging area as 'none' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, 'none' + from diff_diff + join merlin.activity_directive + on activity_id=id + where plan_id = plan_id_receiving + and change_type_supplying = 'none' + and (change_type_receiving = 'modify' or change_type_receiving = 'none') + union + -- supplying 'modify' against receiving 'none' go into the merge staging area as 'modify' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_snapshot(diff_diff.activity_id, snapshot_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.plan_snapshot_activities p + on diff_diff.activity_id = p.id + where snapshot_id = snapshot_id_supplying + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'modify') + union + -- supplying 'delete' against receiving 'none' go into the merge staging area as 'delete' + select _merge_request_id, activity_id, name, tags.tag_ids_activity_directive(diff_diff.activity_id, plan_id), source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, + created_by, last_modified_by, start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type_supplying + from diff_diff + join merlin.activity_directive p + on diff_diff.activity_id = p.id + where plan_id = plan_id_receiving + and (change_type_receiving = 'none' and diff_diff.change_type_supplying = 'delete'); + + -- 'modify' against a 'modify' must be checked for equality first. + with false_modify as ( + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, psa.snapshot_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from merlin.plan_snapshot_activities psa + join diff_diff dd + on dd.activity_id = psa.id + where psa.snapshot_id = snapshot_id_supplying + and (dd.change_type_receiving = 'modify' and dd.change_type_supplying = 'modify') + intersect + select activity_id, name, tags.tag_ids_activity_directive(dd.activity_id, ad.plan_id) as tags, + source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, start_offset, type, arguments, metadata, anchor_id, anchored_to_start + from diff_diff dd + join merlin.activity_directive ad + on dd.activity_id = ad.id + where ad.plan_id = plan_id_receiving + and (dd.change_type_supplying = 'modify' and dd.change_type_receiving = 'modify')) + insert into merlin.merge_staging_area ( + merge_request_id, activity_id, name, tags, source_scheduling_goal_id, source_scheduling_goal_invocation_id, + created_at, created_by, last_modified_by, + start_offset, type, arguments, metadata, anchor_id, anchored_to_start, change_type) + select _merge_request_id, ad.id, ad.name, tags, ad.source_scheduling_goal_id, ad.source_scheduling_goal_invocation_id, + ad.created_at, ad.created_by, ad.last_modified_by, ad.start_offset, ad.type, ad.arguments, ad.metadata, + ad.anchor_id, ad.anchored_to_start, 'none' + from false_modify fm + left join merlin.activity_directive ad + on (ad.plan_id, ad.id) = (plan_id_receiving, fm.activity_id); + + -- 'modify' against 'delete' and inequal 'modify' against 'modify' goes into conflict table (aka everything left in diff_diff) + insert into merlin.conflicting_activities (merge_request_id, activity_id, change_type_supplying, change_type_receiving) + select begin_merge._merge_request_id, activity_id, change_type_supplying, change_type_receiving + from (select begin_merge._merge_request_id, activity_id + from diff_diff + except + select msa.merge_request_id, activity_id + from merlin.merge_staging_area msa) a + join diff_diff using (activity_id); + + -- Fail if there are no differences between the snapshot and the plan getting merged + validate_non_no_op_status := null; + select change_type_receiving + from merlin.conflicting_activities + where merge_request_id = _merge_request_id + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + select change_type + from merlin.merge_staging_area msa + where merge_request_id = _merge_request_id + and msa.change_type != 'none' + limit 1 + into validate_non_no_op_status; + + if validate_non_no_op_status is null then + raise exception 'Cannot begin merge. The contents of the two plans are identical.'; + end if; + end if; + + + -- clean up + drop table supplying_diff; + drop table receiving_diff; + drop table diff_diff; +end +$$; + +call migrations.mark_migration_applied(35); diff --git a/deployment/postgres-init-db/sql/applied_migrations.sql b/deployment/postgres-init-db/sql/applied_migrations.sql index e5c6fba97f..1045aa9e00 100644 --- a/deployment/postgres-init-db/sql/applied_migrations.sql +++ b/deployment/postgres-init-db/sql/applied_migrations.sql @@ -36,3 +36,4 @@ call migrations.mark_migration_applied(31); call migrations.mark_migration_applied(32); call migrations.mark_migration_applied(33); call migrations.mark_migration_applied(34); +call migrations.mark_migration_applied(35); diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql index 3ff2e98c9e..21da6ed3ae 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/begin_merge.sql @@ -29,6 +29,10 @@ create procedure merlin.begin_merge(_merge_request_id integer, review_username t snapshot_id_supplying integer; plan_id_receiving integer; merge_base_id integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; begin -- validate id and status select id, status @@ -50,6 +54,22 @@ begin where id = _merge_request_id into plan_id_receiving, snapshot_id_supplying; + -- ensure that the plans cover the same boundaries + select start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into start_time_receiving, duration_receiving; + + select plan_start_time, plan_duration + from merlin.plan_snapshot ps + where ps.snapshot_id = snapshot_id_supplying + into start_time_supplying, duration_supplying; + + if start_time_receiving is distinct from start_time_supplying or + duration_receiving is distinct from duration_supplying then + raise exception 'Cannot begin merge request between plans with different bounds.'; + end if; + -- ensure the plan receiving changes isn't locked if (select is_locked from merlin.plan where plan.id=plan_id_receiving) then raise exception 'Cannot begin merge request. Plan to receive changes is locked.'; @@ -71,7 +91,6 @@ begin reviewer_username = review_username where id = _merge_request_id; - -- perform diff between mb and s_sc (s_diff) -- delete is B minus A on key -- add is A minus B on key diff --git a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql index 10219bb9ec..dd99943cf5 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/merging/merge_request_state_functions.sql @@ -8,15 +8,27 @@ declare merge_request_id integer; model_id_receiving integer; model_id_supplying integer; + start_time_receiving timestamptz; + duration_receiving interval; + start_time_supplying timestamptz; + duration_supplying interval; begin if plan_id_receiving = plan_id_supplying then raise exception 'Cannot create a merge request between a plan and itself.'; end if; - select id from merlin.plan where plan.id = plan_id_receiving into validate_planIds; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_receiving + into validate_planIds, model_id_receiving, start_time_receiving, duration_receiving; if validate_planIds is null then raise exception 'Plan receiving changes (Plan %) does not exist.', plan_id_receiving; end if; - select id from merlin.plan where plan.id = plan_id_supplying into validate_planIds; + + select id, model_id, start_time, duration + from merlin.plan + where plan.id = plan_id_supplying + into validate_planIds, model_id_supplying, start_time_supplying, duration_supplying; if validate_planIds is null then raise exception 'Plan supplying changes (Plan %) does not exist.', plan_id_supplying; end if; @@ -28,12 +40,15 @@ begin raise exception 'Cannot create merge request between unrelated plans.'; end if; - select model_id from merlin.plan where plan.id = plan_id_receiving into model_id_receiving; - select model_id from merlin.plan where plan.id = plan_id_supplying into model_id_supplying; if model_id_receiving is distinct from model_id_supplying then raise exception 'Cannot create merge request: plan supplying changes is using a different model (%) than the receiving plan (%)', model_id_supplying, model_id_receiving; end if; + if (start_time_receiving is distinct from start_time_supplying) or + (duration_receiving is distinct from duration_supplying) then + raise exception 'Cannot create merge request between plans with different bounds.'; + end if; + insert into merlin.merge_request(plan_id_receiving_changes, snapshot_id_supplying_changes, merge_base_snapshot_id, requester_username) values(plan_id_receiving, supplying_snapshot_id, merge_base_snapshot_id, request_username) returning id into merge_request_id; diff --git a/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql b/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql index 6e97611fa7..fef5be3153 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/snapshots/create_snapshot.sql @@ -19,10 +19,13 @@ begin raise exception 'Plan % does not exist.', _plan_id; end if; - insert into merlin.plan_snapshot(plan_id, model_id, revision, snapshot_name, description, taken_by) - select id, model_id, revision, _snapshot_name, _description, _user + insert into merlin.plan_snapshot(plan_id, model_id, revision, plan_start_time, plan_duration, + snapshot_name, description, taken_by) + select id, model_id, revision, start_time, duration, + _snapshot_name, _description, _user from merlin.plan where id = _plan_id returning snapshot_id into inserted_snapshot_id; + insert into merlin.plan_snapshot_activities( snapshot_id, id, name, source_scheduling_goal_id, source_scheduling_goal_invocation_id, created_at, created_by, last_modified_at, last_modified_by, start_offset, type, @@ -33,10 +36,12 @@ begin last_modified_at, last_modified_by, start_offset, type, arguments, last_modified_arguments_at, metadata, anchor_id, anchored_to_start from merlin.activity_directive where activity_directive.plan_id = _plan_id; + insert into merlin.preset_to_snapshot_directive(preset_id, activity_id, snapshot_id) select ptd.preset_id, ptd.activity_id, inserted_snapshot_id from merlin.preset_to_directive ptd where ptd.plan_id = _plan_id; + insert into tags.snapshot_activity_tags(snapshot_id, directive_id, tag_id) select inserted_snapshot_id, directive_id, tag_id from tags.activity_directive_tags adt diff --git a/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql b/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql index 9b65939e9d..a79a72f3bb 100644 --- a/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql +++ b/deployment/postgres-init-db/sql/functions/merlin/snapshots/restore_from_snapshot.sql @@ -4,6 +4,8 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int _snapshot_name text; _plan_name text; _model_id integer; + _plan_start_time timestamptz; + _plan_duration interval; begin -- Input Validation select name from merlin.plan where id = _plan_id into _plan_name; @@ -23,7 +25,11 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int _snapshot_id, _plan_name, _plan_id; end if; end if; - select model_id from merlin.plan_snapshot where snapshot_id = _snapshot_id into _model_id; + + select model_id, plan_start_time, plan_duration + from merlin.plan_snapshot + where snapshot_id = _snapshot_id + into _model_id, _plan_start_time, _plan_duration; if not exists(select from merlin.mission_model m where m.id = _model_id) then raise exception 'Cannot Restore: Model with ID % does not exist.', _model_id; end if; @@ -31,9 +37,11 @@ create procedure merlin.restore_from_snapshot(_plan_id integer, _snapshot_id int -- Catch Plan_Locked call merlin.plan_locked_exception(_plan_id); - -- Update model_id of the plan + -- Update model_id and bounds of the plan update merlin.plan - set model_id = _model_id + set model_id = _model_id, + start_time = _plan_start_time, + duration = _plan_duration where id = _plan_id; -- Record the Union of Activities in Plan and Snapshot diff --git a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql index 5ddafbb641..0ca7a0ce38 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/activity_directive/activity_directive.sql @@ -129,6 +129,7 @@ end$$; create trigger increment_plan_revision_on_directive_update_trigger after update on merlin.activity_directive for each row +when (pg_trigger_depth() < 1) execute function merlin.increment_plan_revision_on_directive_update(); create function merlin.increment_plan_revision_on_directive_delete() diff --git a/deployment/postgres-init-db/sql/tables/merlin/plan.sql b/deployment/postgres-init-db/sql/tables/merlin/plan.sql index f6a6e756f2..4219201fd9 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/plan.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/plan.sql @@ -134,6 +134,58 @@ for each row when (pg_trigger_depth() < 1) execute function util_functions.increment_revision_update(); +create function merlin.cascade_plan_bounds_update() + returns trigger + language plpgsql as $$ +begin + -- prevent adjustment if the plan is locked + if old.is_locked then + raise exception 'Cannot adjust bounds of locked plan.'; + end if; + + -- Take a backup snapshot + perform merlin.create_snapshot( + old.id, + 'Plan Bound Adjustment', + 'Automatic snapshot made before adjusting plan bounds from ' || + '['|| old.start_time ||' - '|| old.start_time + old.duration || '] to ' || + '[' || new.start_time || ' - ' || new.start_time + new.duration || ']', + null); + + -- Update activities that are anchored to the plan bounds + update merlin.activity_directive ad + set start_offset = start_offset + (new.start_time - old.start_time) + where anchor_id is null + and anchored_to_start -- anchored to plan start + and ad.plan_id = old.id; + + update merlin.activity_directive ad + set start_offset = start_offset + (new.duration - old.duration) + where anchor_id is null + and not anchored_to_start -- anchored to plan end + and ad.plan_id = old.id; + + -- Update associated dataset offsets (simulation and plan) + update merlin.simulation_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + from merlin.simulation sim_spec + where simulation_id = sim_spec.id + and sim_spec.plan_id = old.id; + + update merlin.plan_dataset + set offset_from_plan_start = offset_from_plan_start + (new.start_time - old.start_time) + where plan_id = old.id; + + return new; +end; +$$; + +create trigger cascade_plan_bounds_on_update + before update on merlin.plan + for each row + when (old.start_time is distinct from new.start_time or old.duration is distinct from new.duration) +execute function merlin.cascade_plan_bounds_update(); + -- Delete Triggers create function merlin.cleanup_on_delete() diff --git a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql index 424186c09b..75cb237dfd 100644 --- a/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql +++ b/deployment/postgres-init-db/sql/tables/merlin/snapshot/plan_snapshot.sql @@ -12,13 +12,13 @@ create table merlin.plan_snapshot( references merlin.mission_model on delete set null, revision integer not null, + plan_start_time timestamptz not null, + plan_duration interval not null, snapshot_name text, description text, taken_by text, - taken_at timestamptz not null default now(), - constraint snapshot_name_unique_per_plan - unique (plan_id, snapshot_name) + taken_at timestamptz not null default now() ); comment on table merlin.plan_snapshot is e'' @@ -31,6 +31,10 @@ comment on column merlin.plan_snapshot.model_id is e'' 'The model that this plan was using at the time the snapshot was taken.'; comment on column merlin.plan_snapshot.revision is e'' 'The revision of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_start_time is e'' + 'The start time of the plan at the time the snapshot was taken.'; +comment on column merlin.plan_snapshot.plan_duration is e'' + 'The duration of the plan at the time the snapshot was taken.'; comment on column merlin.plan_snapshot.snapshot_name is e'' 'A human-readable name for the snapshot.'; comment on column merlin.plan_snapshot.description is e''