diff --git a/SAS/TMSS/backend/src/tmss/exceptions.py b/SAS/TMSS/backend/src/tmss/exceptions.py index 82784f607a942acfc3874ee77252b849839f4170..f93899d05d2d98b55bb57a959442f33f5d66183b 100644 --- a/SAS/TMSS/backend/src/tmss/exceptions.py +++ b/SAS/TMSS/backend/src/tmss/exceptions.py @@ -17,6 +17,9 @@ class SubtaskCreationException(ConversionException): class SubtaskException(TMSSException): pass +class SubtaskIllegalStateTransitionException(SubtaskException): + pass + class SubtaskInvalidStateException(TMSSException): pass diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py index b839825872027682578453db8cbf18389a60adcb..e07fb5ce651a899ea02d12ae33e479caf70e25cc 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.9 on 2021-04-07 08:59 +# Generated by Django 3.0.9 on 2021-04-12 06:55 from django.conf import settings import django.contrib.postgres.fields @@ -678,6 +678,18 @@ class Migration(migrations.Migration): ('unique_identifier', models.BigAutoField(help_text='Unique global identifier.', primary_key=True, serialize=False)), ], ), + migrations.CreateModel( + name='StationTimeline', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('station_name', models.CharField(editable=False, help_text='The LOFAR station name.', max_length=16)), + ('timestamp', models.DateField(editable=False, help_text='The date (YYYYMMDD).', null=True)), + ('sunrise_start', models.DateTimeField(help_text='Start time of the sunrise.', null=True)), + ('sunrise_end', models.DateTimeField(help_text='End time of the sunrise.', null=True)), + ('sunset_start', models.DateTimeField(help_text='Start time of the sunset.', null=True)), + ('sunset_end', models.DateTimeField(help_text='End time of the sunset.', null=True)), + ], + ), migrations.CreateModel( name='StationType', fields=[ @@ -704,6 +716,12 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='SubtaskAllowedStateTransitions', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), migrations.CreateModel( name='SubtaskInput', fields=[ @@ -938,17 +956,6 @@ class Migration(migrations.Migration): ('second', models.ForeignKey(help_text='Second Task Blueprint to connect.', on_delete=django.db.models.deletion.CASCADE, related_name='second_scheduling_relation', to='tmssapp.TaskBlueprint')), ], ), - migrations.CreateModel( - name='StationTimeline', - fields=[ - ('station_name', models.CharField(max_length=16, null=False, editable=False, help_text='The LOFAR station name.')), - ('timestamp', models.DateField(editable=False, null=True, help_text='The date (YYYYMMDD).')), - ('sunrise_start', models.DateTimeField(null=True, help_text='Start time of the sunrise.')), - ('sunrise_end', models.DateTimeField(null=True, help_text='End time of the sunrise.')), - ('sunset_start', models.DateTimeField(null=True, help_text='Start time of the sunset.')), - ('sunset_end', models.DateTimeField(null=True, help_text='End time of the sunset.')), - ], - ), migrations.AddConstraint( model_name='taskrelationselectiontemplate', constraint=models.UniqueConstraint(fields=('name', 'version'), name='taskrelationselectiontemplate_unique_name_version'), @@ -1133,6 +1140,16 @@ class Migration(migrations.Migration): name='task_relation_blueprint', field=models.ForeignKey(help_text='Task Relation Blueprint which this Subtask Input implements (NULLable).', null=True, on_delete=django.db.models.deletion.SET_NULL, to='tmssapp.TaskRelationBlueprint'), ), + migrations.AddField( + model_name='subtaskallowedstatetransitions', + name='new_state', + field=models.ForeignKey(editable=False, help_text='Subtask state after update (see Subtask State Machine).', on_delete=django.db.models.deletion.PROTECT, related_name='allowed_transition_to', to='tmssapp.SubtaskState'), + ), + migrations.AddField( + model_name='subtaskallowedstatetransitions', + name='old_state', + field=models.ForeignKey(editable=False, help_text='Subtask state before update (see Subtask State Machine).', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='allowed_transition_from', to='tmssapp.SubtaskState'), + ), migrations.AddField( model_name='subtask', name='cluster', @@ -1165,7 +1182,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='stationtimeline', - constraint=models.UniqueConstraint(fields=('station_name', 'timestamp'), name='unique_station_time_line'), + constraint=models.UniqueConstraint(fields=('station_name', 'timestamp'), name='unique_station_time_line'), ), migrations.AddConstraint( model_name='schedulingunittemplate', diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0002_populate.py b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0002_populate.py index cc01b09d6659e81e82e1001073ba00d044e1aa95..2f1a453273ced309b6018a75b1b650f05963ae8c 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0002_populate.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0002_populate.py @@ -16,24 +16,40 @@ class Migration(migrations.Migration): operations = [ migrations.RunSQL('ALTER SEQUENCE tmssapp_SubTask_id_seq RESTART WITH 2000000;'), # Start SubTask id with 2 000 000 to avoid overlap with 'old' (test/production) OTDB - migrations.RunPython(populate_choices), # poppulates the "fixed" enums, with amongst others the subtask_states which are used next in the trigger - migrations.RunSQL("CREATE OR REPLACE FUNCTION tmssapp_check_subtask_state_transition() \ - RETURNS trigger AS \ - $BODY$ \ - BEGIN \ - IF OLD.state_id='defining' AND NEW.state_id != 'defined' THEN \ - RAISE EXCEPTION 'ILLEGAL SUBTASK STATE TRANSITION FROM % TO %', OLD.state_id, NEW.state_id; \ - END IF; \ - RETURN NEW; \ - END; \ - $BODY$ \ - LANGUAGE plpgsql VOLATILE; \ - DROP TRIGGER IF EXISTS tmssapp_trigger_on_check_subtask_state_transition ON tmssapp_SubTask ; \ - CREATE TRIGGER tmssapp_trigger_on_check_subtask_state_transition \ - AFTER UPDATE ON tmssapp_SubTask \ + # add an SQL trigger in the database enforcing correct state transitions. + # it is crucial that illegal subtask state transitions are block at the "lowest level" (i.e.: in the database) so we can guarantee that the subtask state machine never breaks. + # see: https://support.astron.nl/confluence/display/TMSS/Subtask+State+Machine + # Explanation of SQl below: A trigger function is called upon each create/update of the subtask. + # If the state changes, then it is checked if the state transition from old to new is present in the SubtaskAllowedStateTransitions table. + # If not an Exception is raised, thus enforcing a rollback, thus enforcing the state machine to follow the design. + # It is thereby enforced upon the user/caller to handle these blocked illegal state transitions, and act more wisely. + migrations.RunSQL("CREATE OR REPLACE FUNCTION tmssapp_check_subtask_state_transition() \ + RETURNS trigger AS \ + $BODY$ \ + BEGIN \ + IF TG_OP = 'INSERT' THEN \ + IF NOT (SELECT EXISTS(SELECT id FROM tmssapp_subtaskallowedstatetransitions WHERE old_state_id IS NULL AND new_state_id=NEW.state_id)) THEN \ + RAISE EXCEPTION 'ILLEGAL SUBTASK STATE TRANSITION FROM % TO %', NULL, NEW.state_id; \ + END IF; \ + END IF; \ + IF TG_OP = 'UPDATE' THEN \ + IF OLD.state_id <> NEW.state_id AND NOT (SELECT EXISTS(SELECT id FROM tmssapp_subtaskallowedstatetransitions WHERE old_state_id=OLD.state_id AND new_state_id=NEW.state_id)) THEN \ + RAISE EXCEPTION 'ILLEGAL SUBTASK STATE TRANSITION FROM \"%\" TO \"%\"', OLD.state_id, NEW.state_id; \ + END IF; \ + END IF; \ + RETURN NEW; \ + END; \ + $BODY$ \ + LANGUAGE plpgsql VOLATILE; \ + DROP TRIGGER IF EXISTS tmssapp_trigger_on_check_subtask_state_transition ON tmssapp_SubTask ; \ + CREATE TRIGGER tmssapp_trigger_on_check_subtask_state_transition \ + BEFORE INSERT OR UPDATE ON tmssapp_SubTask \ FOR EACH ROW EXECUTE PROCEDURE tmssapp_check_subtask_state_transition();"), + migrations.RunPython(populate_choices), + migrations.RunPython(populate_subtask_allowed_state_transitions), migrations.RunPython(populate_settings), migrations.RunPython(populate_misc), migrations.RunPython(populate_resources), migrations.RunPython(populate_cycles), migrations.RunPython(populate_projects) ] + diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py b/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py index 234f8ab359cd7dad4c3bb3abcad5d0e4dabc4cd8..86c316ad6047deb56c0033d14ee74f5abb4ab377 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py @@ -16,8 +16,8 @@ from .common import AbstractChoice, BasicCommon, Template, NamedCommon, annotate from enum import Enum from django.db.models.expressions import RawSQL from django.core.exceptions import ValidationError - -from lofar.sas.tmss.tmss.exceptions import SubtaskSchedulingException +from django.db.utils import InternalError +from lofar.sas.tmss.tmss.exceptions import SubtaskSchedulingException, SubtaskIllegalStateTransitionException from django.conf import settings from lofar.sas.resourceassignment.resourceassignmentservice.rpc import RADBRPC import uuid @@ -290,9 +290,16 @@ class Subtask(BasicCommon): # check if we have a start time when scheduling if self.state.value == SubtaskState.Choices.SCHEDULED.value and self.__original_state_id == SubtaskState.Choices.SCHEDULING.value: if self.start_time is None: - raise SubtaskSchedulingException("Cannot schedule subtask id=%s when start time is 'None'." % (self.pk, )) + raise SubtaskSchedulingException("Cannot schedule subtask id=%s when start time is 'None'." % (self.pk, )) - super().save(force_insert, force_update, using, update_fields) + try: + super().save(force_insert, force_update, using, update_fields) + except InternalError as db_error: + # wrap in TMSS SubtaskIllegalStateTransitionException if needed + if 'ILLEGAL SUBTASK STATE TRANSITION' in str(db_error): + raise SubtaskIllegalStateTransitionException(str(db_error)) + # else just reraise + raise # log if either state update or new entry: if self.state_id != self.__original_state_id or creating == True: @@ -308,6 +315,14 @@ class Subtask(BasicCommon): self.__original_state_id = self.state_id +class SubtaskAllowedStateTransitions(Model): + """ + Table with the allowed subtask state transitions. See also the SQL trigger in populate which blocks any subtask state transitions which are not in this table, thus not allowed. + """ + old_state = ForeignKey('SubtaskState', null=True, editable=False, on_delete=PROTECT, related_name='allowed_transition_from', help_text='Subtask state before update (see Subtask State Machine).') + new_state = ForeignKey('SubtaskState', null=False, editable=False, on_delete=PROTECT, related_name='allowed_transition_to', help_text='Subtask state after update (see Subtask State Machine).') + + class SubtaskStateLog(BasicCommon): """ History of state changes on subtasks diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/populate.py b/SAS/TMSS/backend/src/tmss/tmssapp/populate.py index 02575ebfc44cfa7d5261791d741ae95438bfab94..c4ebcdd63cdacaa8666aad6acd0256bb9e196ab4 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/populate.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/populate.py @@ -50,6 +50,55 @@ def populate_choices(apps, schema_editor): executor.map(lambda choice_class: choice_class.objects.bulk_create([choice_class(value=x.value) for x in choice_class.Choices]), choice_classes) +def populate_subtask_allowed_state_transitions(apps, schema_editor): + '''populate the SubtaskAllowedStateTransitions table with the allowed state transitions as defined by the design in https://support.astron.nl/confluence/display/TMSS/Subtask+State+Machine''' + DEFINING = SubtaskState.objects.get(value=SubtaskState.Choices.DEFINING.value) + DEFINED = SubtaskState.objects.get(value=SubtaskState.Choices.DEFINED.value) + SCHEDULING = SubtaskState.objects.get(value=SubtaskState.Choices.SCHEDULING.value) + SCHEDULED = SubtaskState.objects.get(value=SubtaskState.Choices.SCHEDULED.value) + UNSCHEDULING = SubtaskState.objects.get(value=SubtaskState.Choices.UNSCHEDULING.value) + QUEUEING = SubtaskState.objects.get(value=SubtaskState.Choices.QUEUEING.value) + QUEUED = SubtaskState.objects.get(value=SubtaskState.Choices.QUEUED.value) + STARTING = SubtaskState.objects.get(value=SubtaskState.Choices.STARTING.value) + STARTED = SubtaskState.objects.get(value=SubtaskState.Choices.STARTED.value) + FINISHING = SubtaskState.objects.get(value=SubtaskState.Choices.FINISHING.value) + FINISHED = SubtaskState.objects.get(value=SubtaskState.Choices.FINISHED.value) + CANCELLING = SubtaskState.objects.get(value=SubtaskState.Choices.CANCELLING.value) + CANCELLED = SubtaskState.objects.get(value=SubtaskState.Choices.CANCELLED.value) + ERROR = SubtaskState.objects.get(value=SubtaskState.Choices.ERROR.value) + + SubtaskAllowedStateTransitions.objects.bulk_create([ + SubtaskAllowedStateTransitions(old_state=None, new_state=DEFINING), + SubtaskAllowedStateTransitions(old_state=DEFINING, new_state=DEFINED), + SubtaskAllowedStateTransitions(old_state=DEFINED, new_state=SCHEDULING), + SubtaskAllowedStateTransitions(old_state=SCHEDULING, new_state=SCHEDULED), + SubtaskAllowedStateTransitions(old_state=SCHEDULED, new_state=STARTING), # this is an odd one, as most (all?) subtasks are queued before execution... + SubtaskAllowedStateTransitions(old_state=SCHEDULED, new_state=QUEUEING), + SubtaskAllowedStateTransitions(old_state=SCHEDULED, new_state=UNSCHEDULING), + SubtaskAllowedStateTransitions(old_state=UNSCHEDULING, new_state=DEFINED), + SubtaskAllowedStateTransitions(old_state=QUEUEING, new_state=QUEUED), + SubtaskAllowedStateTransitions(old_state=QUEUED, new_state=STARTING), + SubtaskAllowedStateTransitions(old_state=STARTING, new_state=STARTED), + SubtaskAllowedStateTransitions(old_state=STARTED, new_state=FINISHING), + SubtaskAllowedStateTransitions(old_state=FINISHING, new_state=FINISHED), + SubtaskAllowedStateTransitions(old_state=CANCELLING, new_state=CANCELLED), + + SubtaskAllowedStateTransitions(old_state=DEFINING, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=SCHEDULING, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=UNSCHEDULING, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=QUEUEING, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=STARTING, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=STARTED, new_state=ERROR), + SubtaskAllowedStateTransitions(old_state=FINISHING, new_state=ERROR), + + SubtaskAllowedStateTransitions(old_state=DEFINED, new_state=CANCELLING), + SubtaskAllowedStateTransitions(old_state=SCHEDULED, new_state=CANCELLING), + SubtaskAllowedStateTransitions(old_state=QUEUED, new_state=CANCELLING), + SubtaskAllowedStateTransitions(old_state=STARTED, new_state=CANCELLING), + SubtaskAllowedStateTransitions(old_state=FINISHED, new_state=CANCELLING), + SubtaskAllowedStateTransitions(old_state=ERROR, new_state=CANCELLING) + ]) + def populate_settings(apps, schema_editor): Setting.objects.create(name=SystemSettingFlag.objects.get(value='dynamic_scheduling_enabled'), value=False) diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/serializers/scheduling.py b/SAS/TMSS/backend/src/tmss/tmssapp/serializers/scheduling.py index e77cd1e3194586d1dbad0f4591ab987dc953d2fc..a1fa19dfbe1281281f184328e28c9f1528bc0274 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/serializers/scheduling.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/serializers/scheduling.py @@ -16,6 +16,12 @@ class SubtaskStateSerializer(DynamicRelationalHyperlinkedModelSerializer): fields = '__all__' +class SubtaskAllowedStateTransitionsSerializer(DynamicRelationalHyperlinkedModelSerializer): + class Meta: + model = models.SubtaskAllowedStateTransitions + fields = '__all__' + + class SubtaskStateLogSerializer(DynamicRelationalHyperlinkedModelSerializer): class Meta: model = models.SubtaskStateLog diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/scheduling.py b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/scheduling.py index a1201686b644fd793dee48bcf869b90547b9781f..781b7d4bcbcc4aa38cc6dbf900c016bdd09cbe2b 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/scheduling.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/scheduling.py @@ -50,6 +50,11 @@ class SubtaskStateViewSet(LOFARViewSet): serializer_class = serializers.SubtaskStateSerializer +class SubtaskAllowedStateTransitionsViewSet(LOFARViewSet): + queryset = models.SubtaskAllowedStateTransitions.objects.all() + serializer_class = serializers.SubtaskAllowedStateTransitionsSerializer + + class SubtaskStateLogViewSet(LOFARViewSet): queryset = models.SubtaskStateLog.objects.all() serializer_class = serializers.SubtaskStateLogSerializer diff --git a/SAS/TMSS/backend/src/tmss/urls.py b/SAS/TMSS/backend/src/tmss/urls.py index 1772bdc3b68f9bc789166e2efd9f0814ffa0b707..5306787cb405fa524cbb475cc7d7e76d1fe3c561 100644 --- a/SAS/TMSS/backend/src/tmss/urls.py +++ b/SAS/TMSS/backend/src/tmss/urls.py @@ -216,6 +216,7 @@ router.register(r'filesystem', viewsets.FilesystemViewSet) router.register(r'cluster', viewsets.ClusterViewSet) router.register(r'dataproduct_archive_info', viewsets.DataproductArchiveInfoViewSet) router.register(r'dataproduct_hash', viewsets.DataproductHashViewSet) +router.register(r'subtask_allowed_state_transitions', viewsets.SubtaskAllowedStateTransitionsViewSet) router.register(r'subtask_state_log', viewsets.SubtaskStateLogViewSet) router.register(r'user', viewsets.UserViewSet) router.register(r'sap', viewsets.SAPViewSet)