Coverage for backend/django/core/auxiliary/models/Task.py: 86%

108 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Task model offering tracking and coordination for asynchronous operations.""" 

2 

3from typing import Optional 

4from django.db import models 

5from django.db.models import F 

6from django.utils import timezone 

7from authentication.user.models import User 

8from core.auxiliary.enums.generalEnums import TaskStatus 

9from core.auxiliary.models import Flowsheet 

10from core.exceptions import DetailedException 

11from core.managers import IdentityAccessControlManager, StaleFlowsheetState 

12 

13 

14class TaskMeta(models.Model): 

15 """Aggregate counts that parent tasks use to monitor child progress.""" 

16 

17 scheduled_tasks = models.PositiveIntegerField(default=0) 

18 failed_tasks = models.PositiveIntegerField(default=0) 

19 successful_tasks = models.PositiveIntegerField(default=0) 

20 cancelled_tasks = models.PositiveIntegerField(default=0) 

21 

22class TaskType(models.TextChoices): 

23 """Enum of supported task categories.""" 

24 

25 ML_TRAINING = 'ML Training' 

26 IDAES_SOLVE = 'Solve' 

27 BUILD_STATE = 'Build State' 

28 CSV_IMPORT_SCENARIO = 'CSV Import Scenario' 

29 CSV_IMPORT_PINCH_UTILITIES = 'CSV Import Pinch Utilities' 

30 

31 

32class TaskSolveMode(models.TextChoices): 

33 """Explicit solve classification used by revision and cancellation logic.""" 

34 

35 SINGLE_STEADY_STATE = "single_steady_state", "Single steady-state" 

36 MULTI_STEADY_STATE = "multi_steady_state", "Multi steady-state" 

37 DYNAMIC = "dynamic", "Dynamic" 

38 

39class Task(models.Model): 

40 """Represents a unit of work for platform computation (e.g. solving, ML), the status of which is observable 

41 and cancellable by users.""" 

42 

43 task_type = models.CharField(max_length=50, choices=TaskType.choices , default=TaskType.IDAES_SOLVE) 

44 solve_mode = models.CharField( 

45 max_length=24, 

46 choices=TaskSolveMode.choices, 

47 null=True, 

48 blank=True, 

49 ) 

50 creator = models.ForeignKey(User, on_delete=models.CASCADE) 

51 status = models.CharField(choices=TaskStatus.choices , default=TaskStatus.Pending) 

52 flowsheet = models.ForeignKey(Flowsheet, on_delete=models.CASCADE) 

53 flowsheet_state = models.ForeignKey( 

54 "FlowsheetState", 

55 on_delete=models.SET_NULL, 

56 related_name="tasks", 

57 null=True, 

58 blank=True, 

59 ) 

60 start_time = models.DateTimeField(auto_now_add=True) 

61 completed_time = models.DateTimeField(null=True) 

62 error = models.JSONField(null=True) 

63 debug = models.JSONField(null=True) 

64 log = models.TextField(null=True) 

65 parent = models.ForeignKey('Task', on_delete=models.CASCADE, null=True, related_name='children') 

66 metadata = models.OneToOneField(TaskMeta, on_delete=models.CASCADE, null=True, related_name='task') 

67 

68 objects = IdentityAccessControlManager() 

69 

70 class Meta: 

71 ordering = ['-start_time'] 

72 

73 @classmethod 

74 def create( 

75 cls, 

76 creator: User | int, 

77 flowsheet: Flowsheet | int, 

78 task_type: TaskType = TaskType.IDAES_SOLVE, 

79 parent: Optional['Task'] = None, 

80 status: TaskStatus = TaskStatus.Pending, 

81 solve_mode: TaskSolveMode | None = None, 

82 expected_flowsheet_state_id: int | None = None, 

83 save=False 

84 ): 

85 """Instantiate a task, optionally persisting immediately. 

86 

87 Args: 

88 creator: User creating the task or their primary key. 

89 flowsheet: Flowsheet associated with the task or its primary key. 

90 task_type: Category used to tailor downstream behaviour. 

91 parent: Parent task when building a hierarchy of work. 

92 status: Initial status to set on the task. 

93 solve_mode: Explicit solve classification for IDAES work. 

94 expected_flowsheet_state_id: Request-captured state that must still 

95 be current when the task is bound. 

96 save: When true the task is inserted into the database before returning. 

97 

98 Returns: 

99 The newly constructed `Task` instance. 

100 """ 

101 

102 flowsheet_id = flowsheet.pk if isinstance(flowsheet, Flowsheet) else flowsheet 

103 current_flowsheet = Flowsheet.objects.only("pk", "current_state_id").get( 

104 pk=flowsheet_id 

105 ) 

106 if expected_flowsheet_state_id is None: 

107 from core.validation import get_current_flowsheet 

108 

109 expected_flowsheet_state_id = ( 

110 (get_current_flowsheet() or {}).get("flowsheet_state") 

111 ) 

112 if ( 

113 expected_flowsheet_state_id is not None 

114 and current_flowsheet.current_state_id != expected_flowsheet_state_id 

115 ): 

116 raise StaleFlowsheetState() 

117 task = Task( 

118 creator=creator, 

119 flowsheet=current_flowsheet, 

120 flowsheet_state_id=current_flowsheet.current_state_id, 

121 parent=parent, 

122 status=status, 

123 task_type=task_type, 

124 solve_mode=solve_mode, 

125 ) 

126 if save: 126 ↛ 128line 126 didn't jump to line 128 because the condition on line 126 was always true

127 task.save() 

128 return task 

129 

130 @classmethod 

131 def create_parent_task( 

132 cls, 

133 creator: User | int, 

134 flowsheet_id: int, 

135 task_type: TaskType = TaskType.IDAES_SOLVE, 

136 scheduled_tasks: int = 0, 

137 status: TaskStatus = TaskStatus.Pending, 

138 solve_mode: TaskSolveMode | None = None, 

139 expected_flowsheet_state_id: int | None = None, 

140 ): 

141 """Create a parent task seeded with metadata describing child workload.""" 

142 current_flowsheet = Flowsheet.objects.only("pk", "current_state_id").get( 

143 pk=flowsheet_id 

144 ) 

145 if expected_flowsheet_state_id is None: 

146 from core.validation import get_current_flowsheet 

147 

148 expected_flowsheet_state_id = ( 

149 (get_current_flowsheet() or {}).get("flowsheet_state") 

150 ) 

151 if ( 151 ↛ 155line 151 didn't jump to line 155 because the condition on line 151 was never true

152 expected_flowsheet_state_id is not None 

153 and current_flowsheet.current_state_id != expected_flowsheet_state_id 

154 ): 

155 raise StaleFlowsheetState() 

156 parent_task = Task( 

157 creator=creator, 

158 flowsheet=current_flowsheet, 

159 flowsheet_state_id=current_flowsheet.current_state_id, 

160 status=status, 

161 task_type=task_type, 

162 solve_mode=solve_mode, 

163 ) 

164 parent_task.metadata = TaskMeta.objects.create(scheduled_tasks=scheduled_tasks) 

165 parent_task.save() 

166 

167 return parent_task 

168 

169 def clean(self): 

170 """Require optional task provenance to match the stable flowsheet.""" 

171 

172 super().clean() 

173 if ( 

174 self.flowsheet_state_id is not None 

175 and self.flowsheet_state.flowsheet_id != self.flowsheet_id 

176 ): 

177 from django.core.exceptions import ValidationError 

178 

179 raise ValidationError( 

180 {"flowsheet_state": "Task state must belong to the task flowsheet."} 

181 ) 

182 

183 def update_status_from_child(self, child_task: 'Task') -> bool: 

184 """Update parent completion state in response to a child transition. 

185 

186 If a user has already requested cancellation of the parent, preserve the 

187 ``cancelling`` status even when the final child settles normally. The 

188 caller will then resolve the parent to ``cancelled`` once all in-flight 

189 children have finished. 

190 

191 Returns ``True`` only when this call transitions the parent itself into 

192 ``completed``. Callers use that signal to trigger parent-level follow-up 

193 work such as summary notifications. 

194 """ 

195 

196 metadata = self.metadata 

197 if child_task.status == TaskStatus.Failed: 

198 # Use F expressions to avoid race conditions when multiple children update concurrently. 

199 metadata.failed_tasks = F('failed_tasks') + 1 

200 metadata.save(update_fields=['failed_tasks']) 

201 elif child_task.status == TaskStatus.Completed: 201 ↛ 204line 201 didn't jump to line 204 because the condition on line 201 was always true

202 metadata.successful_tasks = F('successful_tasks') + 1 

203 metadata.save(update_fields=['successful_tasks']) 

204 elif child_task.status == TaskStatus.Cancelled: 

205 metadata.cancelled_tasks = F('cancelled_tasks') + 1 

206 metadata.save(update_fields=['cancelled_tasks']) 

207 

208 # Refresh to obtain concrete integer values for comparison. 

209 metadata.refresh_from_db(fields=['failed_tasks', 'successful_tasks', 'cancelled_tasks']) 

210 

211 if ( 

212 metadata.scheduled_tasks 

213 == (metadata.successful_tasks + metadata.failed_tasks + metadata.cancelled_tasks) 

214 ): 

215 completed_time = timezone.now() 

216 transitioned = Task.objects.filter( 

217 pk=self.pk, 

218 status__in=[TaskStatus.Pending, TaskStatus.Running], 

219 flowsheet_state_id=F("flowsheet__current_state_id"), 

220 ).update( 

221 status=TaskStatus.Completed, 

222 completed_time=completed_time, 

223 ) 

224 if transitioned: 

225 self.status = TaskStatus.Completed 

226 self.completed_time = completed_time 

227 return True 

228 self.refresh_from_db(fields=["status", "completed_time"]) 

229 

230 return False 

231 

232 @classmethod 

233 def increment_cancelled_children_for_parent(cls, parent_task_id: int, count: int = 1): 

234 """Increment a parent task's cancelled-child counter in one database update. 

235 

236 When count is greater than 1, multiple child cancellations are batched into 

237 a single update. Returns early without touching the database when count is 

238 zero or negative. 

239 """ 

240 if count <= 0: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 return 

242 

243 TaskMeta.objects.filter(task__id=parent_task_id).update( 

244 cancelled_tasks=F('cancelled_tasks') + count 

245 ) 

246 

247 @staticmethod 

248 def failure_details(exception: Exception | DetailedException) -> dict: 

249 """Serialize an exception into the durable task error contract.""" 

250 detailed_exception = (exception 

251 if isinstance(exception, DetailedException) 

252 else DetailedException(exception, source="") 

253 ) 

254 return { 

255 "message": detailed_exception.message, 

256 "cause": detailed_exception.source, 

257 "traceback": detailed_exception.traceback 

258 } 

259 

260 def set_failure_with_exception(self, exception: Exception | DetailedException, save: bool = False): 

261 """Record failure details on the task, preserving the stack trace.""" 

262 self.status = TaskStatus.Failed 

263 self.error = self.failure_details(exception) 

264 

265 if self.completed_time is None: 265 ↛ 269line 265 didn't jump to line 269 because the condition on line 265 was always true

266 # ensure completed timestamp reflects when failure was logged 

267 self.completed_time = timezone.now() 

268 

269 if save: 269 ↛ exitline 269 didn't return from function 'set_failure_with_exception' because the condition on line 269 was always true

270 self.save()