tilebox-workflows 0.44.0__py3-none-any.whl → 0.46.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tilebox/workflows/automations/client.py +3 -3
- tilebox/workflows/data.py +176 -31
- tilebox/workflows/formatting/job.py +24 -11
- tilebox/workflows/jobs/client.py +38 -12
- tilebox/workflows/jobs/service.py +3 -3
- tilebox/workflows/runner/task_runner.py +38 -9
- tilebox/workflows/task.py +91 -10
- tilebox/workflows/workflows/v1/automation_pb2.py +22 -22
- tilebox/workflows/workflows/v1/automation_pb2.pyi +2 -2
- tilebox/workflows/workflows/v1/core_pb2.py +52 -28
- tilebox/workflows/workflows/v1/core_pb2.pyi +86 -13
- tilebox/workflows/workflows/v1/job_pb2.py +36 -36
- tilebox/workflows/workflows/v1/job_pb2.pyi +17 -11
- tilebox/workflows/workflows/v1/task_pb2.py +16 -16
- tilebox/workflows/workflows/v1/task_pb2.pyi +5 -3
- {tilebox_workflows-0.44.0.dist-info → tilebox_workflows-0.46.0.dist-info}/METADATA +1 -1
- {tilebox_workflows-0.44.0.dist-info → tilebox_workflows-0.46.0.dist-info}/RECORD +18 -18
- {tilebox_workflows-0.44.0.dist-info → tilebox_workflows-0.46.0.dist-info}/WHEEL +1 -1
|
@@ -7,9 +7,9 @@ from tilebox.workflows.clusters.client import ClusterSlugLike, to_cluster_slug
|
|
|
7
7
|
from tilebox.workflows.data import (
|
|
8
8
|
AutomationPrototype,
|
|
9
9
|
CronTrigger,
|
|
10
|
+
SingleTaskSubmission,
|
|
10
11
|
StorageEventTrigger,
|
|
11
12
|
StorageLocation,
|
|
12
|
-
TaskSubmission,
|
|
13
13
|
)
|
|
14
14
|
from tilebox.workflows.task import _task_meta
|
|
15
15
|
|
|
@@ -76,7 +76,7 @@ class AutomationClient:
|
|
|
76
76
|
automation = AutomationPrototype(
|
|
77
77
|
id=UUID(int=0),
|
|
78
78
|
name=name,
|
|
79
|
-
prototype=
|
|
79
|
+
prototype=SingleTaskSubmission(
|
|
80
80
|
cluster_slug=to_cluster_slug(cluster or ""),
|
|
81
81
|
identifier=_task_meta(task).identifier,
|
|
82
82
|
input=task._serialize_args(), # noqa: SLF001
|
|
@@ -118,7 +118,7 @@ class AutomationClient:
|
|
|
118
118
|
automation = AutomationPrototype(
|
|
119
119
|
id=UUID(int=0),
|
|
120
120
|
name=name,
|
|
121
|
-
prototype=
|
|
121
|
+
prototype=SingleTaskSubmission(
|
|
122
122
|
cluster_slug=to_cluster_slug(cluster or ""),
|
|
123
123
|
identifier=_task_meta(task).identifier,
|
|
124
124
|
input=task._serialize_args(), # noqa: SLF001
|
tilebox/workflows/data.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
|
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from functools import lru_cache
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
9
|
+
from typing import Any, cast
|
|
10
10
|
from uuid import UUID
|
|
11
11
|
|
|
12
12
|
import boto3
|
|
@@ -23,7 +23,7 @@ from tilebox.datasets.query.time_interval import (
|
|
|
23
23
|
timedelta_to_duration,
|
|
24
24
|
timestamp_to_datetime,
|
|
25
25
|
)
|
|
26
|
-
from tilebox.datasets.uuid import
|
|
26
|
+
from tilebox.datasets.uuid import must_uuid_to_uuid_message, uuid_message_to_uuid, uuid_to_uuid_message
|
|
27
27
|
|
|
28
28
|
try:
|
|
29
29
|
# let's not make this a hard dependency, but if it's installed we can use its types
|
|
@@ -162,7 +162,7 @@ class Task:
|
|
|
162
162
|
return core_pb2.Task(
|
|
163
163
|
id=uuid_to_uuid_message(self.id),
|
|
164
164
|
identifier=self.identifier.to_message(),
|
|
165
|
-
state=
|
|
165
|
+
state=cast(core_pb2.TaskState, self.state.value),
|
|
166
166
|
input=self.input,
|
|
167
167
|
display=self.display,
|
|
168
168
|
job=self.job.to_message() if self.job else None,
|
|
@@ -189,13 +189,66 @@ class Idling:
|
|
|
189
189
|
|
|
190
190
|
class JobState(Enum):
|
|
191
191
|
UNSPECIFIED = 0
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
SUBMITTED = 1
|
|
193
|
+
RUNNING = 2
|
|
194
|
+
STARTED = 3
|
|
195
|
+
COMPLETED = 4
|
|
196
|
+
FAILED = 5
|
|
197
|
+
CANCELED = 6
|
|
195
198
|
|
|
196
199
|
|
|
197
200
|
_JOB_STATES = {state.value: state for state in JobState}
|
|
198
201
|
|
|
202
|
+
# JobState.QUEUED is deprecated and has been renamed to SUBMITTED, but we keep it around for backwards compatibility
|
|
203
|
+
JobState.QUEUED = JobState.SUBMITTED # type: ignore[assignment]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(order=True, frozen=True)
|
|
207
|
+
class ExecutionStats:
|
|
208
|
+
first_task_started_at: datetime | None
|
|
209
|
+
last_task_stopped_at: datetime | None
|
|
210
|
+
compute_time: timedelta
|
|
211
|
+
elapsed_time: timedelta
|
|
212
|
+
parallelism: float
|
|
213
|
+
total_tasks: int
|
|
214
|
+
tasks_by_state: dict[TaskState, int]
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_message(cls, execution_stats: core_pb2.ExecutionStats) -> "ExecutionStats":
|
|
218
|
+
"""Convert a ExecutionStats protobuf message to a ExecutionStats object."""
|
|
219
|
+
return cls(
|
|
220
|
+
first_task_started_at=timestamp_to_datetime(execution_stats.first_task_started_at)
|
|
221
|
+
if execution_stats.HasField("first_task_started_at")
|
|
222
|
+
else None,
|
|
223
|
+
last_task_stopped_at=timestamp_to_datetime(execution_stats.last_task_stopped_at)
|
|
224
|
+
if execution_stats.HasField("last_task_stopped_at")
|
|
225
|
+
else None,
|
|
226
|
+
compute_time=duration_to_timedelta(execution_stats.compute_time),
|
|
227
|
+
elapsed_time=duration_to_timedelta(execution_stats.elapsed_time),
|
|
228
|
+
parallelism=execution_stats.parallelism,
|
|
229
|
+
total_tasks=execution_stats.total_tasks,
|
|
230
|
+
tasks_by_state={_TASK_STATES[state.state]: state.count for state in execution_stats.tasks_by_state},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def to_message(self) -> core_pb2.ExecutionStats:
|
|
234
|
+
"""Convert a ExecutionStats object to a ExecutionStats protobuf message."""
|
|
235
|
+
return core_pb2.ExecutionStats(
|
|
236
|
+
first_task_started_at=datetime_to_timestamp(self.first_task_started_at)
|
|
237
|
+
if self.first_task_started_at
|
|
238
|
+
else None,
|
|
239
|
+
last_task_stopped_at=datetime_to_timestamp(self.last_task_stopped_at)
|
|
240
|
+
if self.last_task_stopped_at
|
|
241
|
+
else None,
|
|
242
|
+
compute_time=timedelta_to_duration(self.compute_time),
|
|
243
|
+
elapsed_time=timedelta_to_duration(self.elapsed_time),
|
|
244
|
+
parallelism=self.parallelism,
|
|
245
|
+
total_tasks=self.total_tasks,
|
|
246
|
+
tasks_by_state=[
|
|
247
|
+
core_pb2.TaskStateCount(state=cast(core_pb2.TaskState, state.value), count=count)
|
|
248
|
+
for state, count in self.tasks_by_state.items()
|
|
249
|
+
],
|
|
250
|
+
)
|
|
251
|
+
|
|
199
252
|
|
|
200
253
|
@dataclass(order=True, frozen=True)
|
|
201
254
|
class Job:
|
|
@@ -204,9 +257,8 @@ class Job:
|
|
|
204
257
|
trace_parent: str
|
|
205
258
|
state: JobState
|
|
206
259
|
submitted_at: datetime
|
|
207
|
-
started_at: datetime | None
|
|
208
|
-
canceled: bool
|
|
209
260
|
progress: list[ProgressIndicator]
|
|
261
|
+
execution_stats: ExecutionStats
|
|
210
262
|
|
|
211
263
|
@classmethod
|
|
212
264
|
def from_message(
|
|
@@ -219,9 +271,8 @@ class Job:
|
|
|
219
271
|
trace_parent=job.trace_parent,
|
|
220
272
|
state=_JOB_STATES[job.state],
|
|
221
273
|
submitted_at=timestamp_to_datetime(job.submitted_at),
|
|
222
|
-
started_at=timestamp_to_datetime(job.started_at) if job.HasField("started_at") else None,
|
|
223
|
-
canceled=job.canceled,
|
|
224
274
|
progress=[ProgressIndicator.from_message(progress) for progress in job.progress],
|
|
275
|
+
execution_stats=ExecutionStats.from_message(job.execution_stats),
|
|
225
276
|
**extra_kwargs,
|
|
226
277
|
)
|
|
227
278
|
|
|
@@ -231,13 +282,33 @@ class Job:
|
|
|
231
282
|
id=uuid_to_uuid_message(self.id),
|
|
232
283
|
name=self.name,
|
|
233
284
|
trace_parent=self.trace_parent,
|
|
234
|
-
state=
|
|
285
|
+
state=cast(core_pb2.JobState, self.state.value),
|
|
235
286
|
submitted_at=datetime_to_timestamp(self.submitted_at),
|
|
236
|
-
started_at=datetime_to_timestamp(self.started_at) if self.started_at else None,
|
|
237
|
-
canceled=self.canceled,
|
|
238
287
|
progress=[progress.to_message() for progress in self.progress],
|
|
288
|
+
execution_stats=self.execution_stats.to_message(),
|
|
239
289
|
)
|
|
240
290
|
|
|
291
|
+
@property
|
|
292
|
+
def canceled(self) -> bool:
|
|
293
|
+
warnings.warn(
|
|
294
|
+
"The canceled property on a job has been deprecated, and will be removed in a future version. "
|
|
295
|
+
"Use job.state == JobState.CANCELED, or job.state == JobState.FAILED instead.",
|
|
296
|
+
DeprecationWarning,
|
|
297
|
+
stacklevel=2,
|
|
298
|
+
)
|
|
299
|
+
# the deprecated canceled property was also true for failed jobs, so we keep that behavior
|
|
300
|
+
return self.state in (JobState.CANCELED, JobState.FAILED)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def started_at(self) -> datetime | None:
|
|
304
|
+
warnings.warn(
|
|
305
|
+
"The started_at property on a job has been deprecated, use `job.execution_stats.first_task_started_at` "
|
|
306
|
+
"instead.",
|
|
307
|
+
DeprecationWarning,
|
|
308
|
+
stacklevel=2,
|
|
309
|
+
)
|
|
310
|
+
return self.execution_stats.first_task_started_at
|
|
311
|
+
|
|
241
312
|
|
|
242
313
|
@dataclass(order=True)
|
|
243
314
|
class Cluster:
|
|
@@ -271,7 +342,69 @@ class NextTaskToRun:
|
|
|
271
342
|
|
|
272
343
|
|
|
273
344
|
@dataclass
|
|
274
|
-
class
|
|
345
|
+
class TaskSubmissionGroup:
|
|
346
|
+
dependencies_on_other_groups: list[int]
|
|
347
|
+
inputs: list[bytes] = field(default_factory=list)
|
|
348
|
+
identifier_pointers: list[int] = field(default_factory=list)
|
|
349
|
+
cluster_slug_pointers: list[int] = field(default_factory=list)
|
|
350
|
+
display_pointers: list[int] = field(default_factory=list)
|
|
351
|
+
max_retries_values: list[int] = field(default_factory=list)
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def from_message(cls, group: core_pb2.TaskSubmissionGroup) -> "TaskSubmissionGroup":
|
|
355
|
+
"""Convert a TaskSubmissionGroup protobuf message to a TaskSubmissionGroup object."""
|
|
356
|
+
return cls(
|
|
357
|
+
dependencies_on_other_groups=list(group.dependencies_on_other_groups),
|
|
358
|
+
inputs=list(group.inputs),
|
|
359
|
+
identifier_pointers=list(group.identifier_pointers),
|
|
360
|
+
cluster_slug_pointers=list(group.cluster_slug_pointers),
|
|
361
|
+
display_pointers=list(group.display_pointers),
|
|
362
|
+
max_retries_values=list(group.max_retries_values),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def to_message(self) -> core_pb2.TaskSubmissionGroup:
|
|
366
|
+
"""Convert a TaskSubmissionGroup object to a TaskSubmissionGroup protobuf message."""
|
|
367
|
+
return core_pb2.TaskSubmissionGroup(
|
|
368
|
+
dependencies_on_other_groups=self.dependencies_on_other_groups,
|
|
369
|
+
inputs=self.inputs,
|
|
370
|
+
identifier_pointers=self.identifier_pointers,
|
|
371
|
+
cluster_slug_pointers=self.cluster_slug_pointers,
|
|
372
|
+
display_pointers=self.display_pointers,
|
|
373
|
+
max_retries_values=self.max_retries_values,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclass
|
|
378
|
+
class TaskSubmissions:
|
|
379
|
+
task_groups: list[TaskSubmissionGroup]
|
|
380
|
+
cluster_slug_lookup: list[str]
|
|
381
|
+
identifier_lookup: list[TaskIdentifier]
|
|
382
|
+
display_lookup: list[str]
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def from_message(cls, sub_task: core_pb2.TaskSubmissions) -> "TaskSubmissions":
|
|
386
|
+
"""Convert a TaskSubmission protobuf message to a TaskSubmission object."""
|
|
387
|
+
return cls(
|
|
388
|
+
task_groups=[TaskSubmissionGroup.from_message(group) for group in sub_task.task_groups],
|
|
389
|
+
cluster_slug_lookup=list(sub_task.cluster_slug_lookup),
|
|
390
|
+
identifier_lookup=[TaskIdentifier.from_message(identifier) for identifier in sub_task.identifier_lookup],
|
|
391
|
+
display_lookup=list(sub_task.display_lookup),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def to_message(self) -> core_pb2.TaskSubmissions:
|
|
395
|
+
"""Convert a TaskSubmissions object to a TaskSubmissions protobuf message."""
|
|
396
|
+
return core_pb2.TaskSubmissions(
|
|
397
|
+
task_groups=[group.to_message() for group in self.task_groups],
|
|
398
|
+
cluster_slug_lookup=self.cluster_slug_lookup,
|
|
399
|
+
identifier_lookup=[identifier.to_message() for identifier in self.identifier_lookup],
|
|
400
|
+
display_lookup=self.display_lookup,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@dataclass
|
|
405
|
+
class SingleTaskSubmission:
|
|
406
|
+
"""A submission of a single task. Used for automations."""
|
|
407
|
+
|
|
275
408
|
cluster_slug: str
|
|
276
409
|
identifier: TaskIdentifier
|
|
277
410
|
input: bytes
|
|
@@ -280,8 +413,8 @@ class TaskSubmission:
|
|
|
280
413
|
max_retries: int = 0
|
|
281
414
|
|
|
282
415
|
@classmethod
|
|
283
|
-
def from_message(cls, sub_task: core_pb2.
|
|
284
|
-
"""Convert a TaskSubmission protobuf message to a
|
|
416
|
+
def from_message(cls, sub_task: core_pb2.SingleTaskSubmission) -> "SingleTaskSubmission":
|
|
417
|
+
"""Convert a TaskSubmission protobuf message to a SingleTaskSubmission object."""
|
|
285
418
|
return cls(
|
|
286
419
|
cluster_slug=sub_task.cluster_slug,
|
|
287
420
|
identifier=TaskIdentifier.from_message(sub_task.identifier),
|
|
@@ -291,9 +424,9 @@ class TaskSubmission:
|
|
|
291
424
|
max_retries=sub_task.max_retries,
|
|
292
425
|
)
|
|
293
426
|
|
|
294
|
-
def to_message(self) -> core_pb2.
|
|
295
|
-
"""Convert a
|
|
296
|
-
return core_pb2.
|
|
427
|
+
def to_message(self) -> core_pb2.SingleTaskSubmission:
|
|
428
|
+
"""Convert a SingleTaskSubmission object to a TaskSubmission protobuf message."""
|
|
429
|
+
return core_pb2.SingleTaskSubmission(
|
|
297
430
|
cluster_slug=self.cluster_slug,
|
|
298
431
|
identifier=self.identifier.to_message(),
|
|
299
432
|
input=self.input,
|
|
@@ -307,7 +440,7 @@ class TaskSubmission:
|
|
|
307
440
|
class ComputedTask:
|
|
308
441
|
id: UUID
|
|
309
442
|
display: str | None
|
|
310
|
-
sub_tasks:
|
|
443
|
+
sub_tasks: TaskSubmissions | None
|
|
311
444
|
progress_updates: list[ProgressIndicator]
|
|
312
445
|
|
|
313
446
|
@classmethod
|
|
@@ -316,7 +449,9 @@ class ComputedTask:
|
|
|
316
449
|
return cls(
|
|
317
450
|
id=uuid_message_to_uuid(computed_task.id),
|
|
318
451
|
display=computed_task.display,
|
|
319
|
-
sub_tasks=
|
|
452
|
+
sub_tasks=TaskSubmissions.from_message(computed_task.sub_tasks)
|
|
453
|
+
if computed_task.HasField("sub_tasks")
|
|
454
|
+
else None,
|
|
320
455
|
progress_updates=[ProgressIndicator.from_message(progress) for progress in computed_task.progress_updates],
|
|
321
456
|
)
|
|
322
457
|
|
|
@@ -325,7 +460,7 @@ class ComputedTask:
|
|
|
325
460
|
return task_pb2.ComputedTask(
|
|
326
461
|
id=uuid_to_uuid_message(self.id),
|
|
327
462
|
display=self.display,
|
|
328
|
-
sub_tasks=
|
|
463
|
+
sub_tasks=self.sub_tasks.to_message() if self.sub_tasks else None,
|
|
329
464
|
progress_updates=[progress.to_message() for progress in self.progress_updates],
|
|
330
465
|
)
|
|
331
466
|
|
|
@@ -505,7 +640,7 @@ class TriggeredCronEvent:
|
|
|
505
640
|
class AutomationPrototype:
|
|
506
641
|
id: UUID
|
|
507
642
|
name: str
|
|
508
|
-
prototype:
|
|
643
|
+
prototype: SingleTaskSubmission
|
|
509
644
|
storage_event_triggers: list[StorageEventTrigger]
|
|
510
645
|
cron_triggers: list[CronTrigger]
|
|
511
646
|
|
|
@@ -515,7 +650,7 @@ class AutomationPrototype:
|
|
|
515
650
|
return cls(
|
|
516
651
|
id=uuid_message_to_uuid(task.id),
|
|
517
652
|
name=task.name,
|
|
518
|
-
prototype=
|
|
653
|
+
prototype=SingleTaskSubmission.from_message(task.prototype),
|
|
519
654
|
storage_event_triggers=[
|
|
520
655
|
StorageEventTrigger.from_message(trigger) for trigger in task.storage_event_triggers
|
|
521
656
|
],
|
|
@@ -595,21 +730,31 @@ class QueryJobsResponse:
|
|
|
595
730
|
|
|
596
731
|
@dataclass(frozen=True)
|
|
597
732
|
class QueryFilters:
|
|
598
|
-
time_interval: TimeInterval | None
|
|
599
|
-
id_interval: IDInterval | None
|
|
600
|
-
|
|
733
|
+
time_interval: TimeInterval | None
|
|
734
|
+
id_interval: IDInterval | None
|
|
735
|
+
automation_ids: list[UUID]
|
|
736
|
+
job_states: list[JobState]
|
|
737
|
+
name: str | None
|
|
601
738
|
|
|
602
739
|
@classmethod
|
|
603
740
|
def from_message(cls, filters: job_pb2.QueryFilters) -> "QueryFilters":
|
|
604
741
|
return cls(
|
|
605
|
-
time_interval=TimeInterval.from_message(filters.time_interval)
|
|
606
|
-
|
|
607
|
-
|
|
742
|
+
time_interval=TimeInterval.from_message(filters.time_interval)
|
|
743
|
+
if filters.HasField("time_interval")
|
|
744
|
+
else None,
|
|
745
|
+
id_interval=IDInterval.from_message(filters.id_interval) if filters.HasField("id_interval") else None,
|
|
746
|
+
automation_ids=[uuid_message_to_uuid(uuid) for uuid in filters.automation_ids],
|
|
747
|
+
job_states=[_JOB_STATES[state] for state in filters.states],
|
|
748
|
+
name=filters.name or None,
|
|
608
749
|
)
|
|
609
750
|
|
|
610
751
|
def to_message(self) -> job_pb2.QueryFilters:
|
|
611
752
|
return job_pb2.QueryFilters(
|
|
612
753
|
time_interval=self.time_interval.to_message() if self.time_interval else None,
|
|
613
754
|
id_interval=self.id_interval.to_message() if self.id_interval else None,
|
|
614
|
-
|
|
755
|
+
automation_ids=[must_uuid_to_uuid_message(uuid) for uuid in self.automation_ids]
|
|
756
|
+
if self.automation_ids
|
|
757
|
+
else None,
|
|
758
|
+
states=[cast(core_pb2.JobState, state.value) for state in self.job_states] if self.job_states else None,
|
|
759
|
+
name=self.name or None, # empty string becomes None
|
|
615
760
|
)
|
|
@@ -35,9 +35,7 @@ class JobWidget:
|
|
|
35
35
|
self.widgets.append(HTML(_render_job_details_html(self.job)))
|
|
36
36
|
self.widgets.append(HTML(_render_job_progress(self.job, False)))
|
|
37
37
|
self.widgets.extend(
|
|
38
|
-
_progress_indicator_bar(
|
|
39
|
-
progress.label or self.job.name, progress.done, progress.total, self.job.canceled
|
|
40
|
-
)
|
|
38
|
+
_progress_indicator_bar(progress.label or self.job.name, progress.done, progress.total, self.job.state)
|
|
41
39
|
for progress in self.job.progress
|
|
42
40
|
)
|
|
43
41
|
self.layout = VBox(self.widgets)
|
|
@@ -63,14 +61,17 @@ class JobWidget:
|
|
|
63
61
|
if last_progress is None: # first time, don't add the refresh time
|
|
64
62
|
self.widgets[1] = HTML(_render_job_progress(progress, False))
|
|
65
63
|
updated = True
|
|
66
|
-
elif
|
|
64
|
+
elif (
|
|
65
|
+
progress.state != last_progress.state
|
|
66
|
+
or progress.execution_stats.first_task_started_at != last_progress.execution_stats.first_task_started_at
|
|
67
|
+
):
|
|
67
68
|
self.widgets[1] = HTML(_render_job_progress(progress, True))
|
|
68
69
|
updated = True
|
|
69
70
|
|
|
70
71
|
if last_progress is None or progress.progress != last_progress.progress:
|
|
71
72
|
self.widgets[2:] = [
|
|
72
73
|
_progress_indicator_bar(
|
|
73
|
-
progress.label or self.job.name, progress.done, progress.total, self.job.
|
|
74
|
+
progress.label or self.job.name, progress.done, progress.total, self.job.state
|
|
74
75
|
)
|
|
75
76
|
for progress in progress.progress
|
|
76
77
|
]
|
|
@@ -193,7 +194,7 @@ body.vscode-dark {
|
|
|
193
194
|
padding: 2px 10px;
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
.tbx-job-state-
|
|
197
|
+
.tbx-job-state-submitted {
|
|
197
198
|
background-color: #f1f5f9;
|
|
198
199
|
color: #0f172a;
|
|
199
200
|
}
|
|
@@ -203,6 +204,11 @@ body.vscode-dark {
|
|
|
203
204
|
color: #f8fafc;
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
.tbx-job-state-started {
|
|
208
|
+
background-color: #fd9b11;
|
|
209
|
+
color: #f8fafc;
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
.tbx-job-state-completed {
|
|
207
213
|
background-color: #21c45d;
|
|
208
214
|
color: #f8fafc;
|
|
@@ -213,6 +219,11 @@ body.vscode-dark {
|
|
|
213
219
|
color: #f8fafc;
|
|
214
220
|
}
|
|
215
221
|
|
|
222
|
+
.tbx-job-state-canceled {
|
|
223
|
+
background-color: #94a2b3;
|
|
224
|
+
color: #f8fafc;
|
|
225
|
+
}
|
|
226
|
+
|
|
216
227
|
.tbx-job-progress a {
|
|
217
228
|
text-decoration: underline;
|
|
218
229
|
}
|
|
@@ -285,20 +296,20 @@ def _render_job_progress(job: Job, include_refresh_time: bool) -> str:
|
|
|
285
296
|
refresh = f" <span class='tbx-detail-value-muted'>(refreshed at {current_time.strftime('%H:%M:%S')})</span> {_info_icon}"
|
|
286
297
|
|
|
287
298
|
state_name = job.state.name
|
|
288
|
-
if job.state == JobState.STARTED:
|
|
289
|
-
state_name = "RUNNING" if not job.canceled else "FAILED"
|
|
290
299
|
|
|
291
300
|
no_progress = ""
|
|
292
301
|
if not job.progress:
|
|
293
302
|
no_progress = "<span class='tbx-detail-value-muted'>No user defined progress indicators. <a href='https://docs.tilebox.com/workflows/progress' target='_blank'>Learn more</a></span>"
|
|
294
303
|
|
|
304
|
+
started_at = job.execution_stats.first_task_started_at
|
|
305
|
+
|
|
295
306
|
"""Render a job's progress as HTML, needs to be called after render_job_details_html since that injects the necessary CSS."""
|
|
296
307
|
return f"""
|
|
297
308
|
<div class="tbx-wrap">
|
|
298
309
|
<div class="tbx-obj-type">Progress{refresh}</div>
|
|
299
310
|
<div class="tbx-job-progress">
|
|
300
311
|
<div><span class="tbx-detail-key tbx-detail-mono">state:</span> <span class="tbx-job-state tbx-job-state-{state_name.lower()}">{state_name}</span><div>
|
|
301
|
-
<div><span class="tbx-detail-key tbx-detail-mono">started_at:</span> {_render_datetime(
|
|
312
|
+
<div><span class="tbx-detail-key tbx-detail-mono">started_at:</span> {_render_datetime(started_at) if started_at else "<span class='tbx-detail-value-muted tbx-detail-mono'>None</span>"}<div>
|
|
302
313
|
<div><span class="tbx-detail-key tbx-detail-mono">progress:</span> {no_progress}</div>
|
|
303
314
|
</div>
|
|
304
315
|
</div>
|
|
@@ -312,9 +323,11 @@ _BAR_COLORS = {
|
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
|
|
315
|
-
def _progress_indicator_bar(label: str, done: int, total: int,
|
|
326
|
+
def _progress_indicator_bar(label: str, done: int, total: int, state: JobState) -> HBox:
|
|
316
327
|
percentage = done / total if total > 0 else 0 if done <= total else 1
|
|
317
|
-
non_completed_color =
|
|
328
|
+
non_completed_color = (
|
|
329
|
+
_BAR_COLORS["failed"] if state in (JobState.FAILED, JobState.CANCELED) else _BAR_COLORS["running"]
|
|
330
|
+
)
|
|
318
331
|
progress = IntProgress(
|
|
319
332
|
min=0,
|
|
320
333
|
max=total,
|
tilebox/workflows/jobs/client.py
CHANGED
|
@@ -9,12 +9,13 @@ from tilebox.datasets.query.time_interval import TimeInterval, TimeIntervalLike
|
|
|
9
9
|
from tilebox.workflows.clusters.client import ClusterSlugLike, to_cluster_slug
|
|
10
10
|
from tilebox.workflows.data import (
|
|
11
11
|
Job,
|
|
12
|
+
JobState,
|
|
12
13
|
QueryFilters,
|
|
13
14
|
QueryJobsResponse,
|
|
14
15
|
)
|
|
15
16
|
from tilebox.workflows.jobs.service import JobService
|
|
16
17
|
from tilebox.workflows.observability.tracing import WorkflowTracer, get_trace_parent_of_current_span
|
|
17
|
-
from tilebox.workflows.task import FutureTask
|
|
18
|
+
from tilebox.workflows.task import FutureTask, merge_future_tasks_to_submissions
|
|
18
19
|
from tilebox.workflows.task import Task as TaskInstance
|
|
19
20
|
|
|
20
21
|
try:
|
|
@@ -64,8 +65,9 @@ class JobClient:
|
|
|
64
65
|
"""
|
|
65
66
|
tasks = root_task_or_tasks if isinstance(root_task_or_tasks, list) else [root_task_or_tasks]
|
|
66
67
|
|
|
68
|
+
default_cluster = ""
|
|
67
69
|
if isinstance(cluster, ClusterSlugLike | None):
|
|
68
|
-
slugs = [to_cluster_slug(cluster or
|
|
70
|
+
slugs = [to_cluster_slug(cluster or default_cluster)] * len(tasks)
|
|
69
71
|
else:
|
|
70
72
|
slugs = [to_cluster_slug(c) for c in cluster]
|
|
71
73
|
|
|
@@ -75,13 +77,14 @@ class JobClient:
|
|
|
75
77
|
f"or exactly one cluster per task. But got {len(tasks)} tasks and {len(slugs)} clusters."
|
|
76
78
|
)
|
|
77
79
|
|
|
78
|
-
task_submissions = [
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
task_submissions = [FutureTask(i, task, [], slugs[i], max_retries) for i, task in enumerate(tasks)]
|
|
81
|
+
submissions_merged = merge_future_tasks_to_submissions(task_submissions, default_cluster)
|
|
82
|
+
if submissions_merged is None:
|
|
83
|
+
raise ValueError("At least one task must be submitted.")
|
|
81
84
|
|
|
82
85
|
with self._tracer.start_as_current_span(f"job/{job_name}"):
|
|
83
86
|
trace_parent = get_trace_parent_of_current_span()
|
|
84
|
-
return self._service.submit(job_name, trace_parent,
|
|
87
|
+
return self._service.submit(job_name, trace_parent, submissions_merged)
|
|
85
88
|
|
|
86
89
|
def retry(self, job_or_id: JobIDLike) -> int:
|
|
87
90
|
"""Retry a job.
|
|
@@ -154,7 +157,13 @@ class JobClient:
|
|
|
154
157
|
"""
|
|
155
158
|
return self._service.visualize(_to_uuid(job), direction, layout, sketchy)
|
|
156
159
|
|
|
157
|
-
def query(
|
|
160
|
+
def query(
|
|
161
|
+
self,
|
|
162
|
+
temporal_extent: TimeIntervalLike | IDIntervalLike,
|
|
163
|
+
automation_ids: UUID | list[UUID] | None = None,
|
|
164
|
+
job_states: JobState | list[JobState] | None = None,
|
|
165
|
+
name: str | None = None,
|
|
166
|
+
) -> list[Job]:
|
|
158
167
|
"""List jobs in the given temporal extent.
|
|
159
168
|
|
|
160
169
|
Args:
|
|
@@ -170,11 +179,14 @@ class JobClient:
|
|
|
170
179
|
- tuple of two UUIDs: [start, end) -> Construct an IDInterval with the given start and end id
|
|
171
180
|
- tuple of two strings: [start, end) -> Construct an IDInterval with the given start and end id
|
|
172
181
|
parsed from the strings
|
|
173
|
-
|
|
174
|
-
are returned.
|
|
175
|
-
|
|
182
|
+
automation_ids: An automation id or list of automation ids to filter jobs by.
|
|
183
|
+
If specified, only jobs created by one of the selected automations are returned.
|
|
184
|
+
job_states: A job state or list of job states to filter jobs by. If specified, only jobs in one of the
|
|
185
|
+
selected states are returned.
|
|
186
|
+
name: A name to filter jobs by. If specified, only jobs with a matching name are returned. The match is
|
|
187
|
+
case-insensitive and uses a fuzzy matching scheme.
|
|
176
188
|
Returns:
|
|
177
|
-
A list of jobs.
|
|
189
|
+
A list of jobs matching the given filters.
|
|
178
190
|
"""
|
|
179
191
|
time_interval: TimeInterval | None = None
|
|
180
192
|
id_interval: IDInterval | None = None
|
|
@@ -202,7 +214,21 @@ class JobClient:
|
|
|
202
214
|
end_inclusive=dataset_time_interval.end_inclusive,
|
|
203
215
|
)
|
|
204
216
|
|
|
205
|
-
|
|
217
|
+
automation_ids = automation_ids or []
|
|
218
|
+
if not isinstance(automation_ids, list):
|
|
219
|
+
automation_ids = [automation_ids]
|
|
220
|
+
|
|
221
|
+
job_states = job_states or []
|
|
222
|
+
if not isinstance(job_states, list):
|
|
223
|
+
job_states = [job_states]
|
|
224
|
+
|
|
225
|
+
filters = QueryFilters(
|
|
226
|
+
time_interval=time_interval,
|
|
227
|
+
id_interval=id_interval,
|
|
228
|
+
automation_ids=automation_ids,
|
|
229
|
+
job_states=job_states,
|
|
230
|
+
name=name,
|
|
231
|
+
)
|
|
206
232
|
|
|
207
233
|
def request(page: PaginationProtocol) -> QueryJobsResponse:
|
|
208
234
|
query_page = Pagination(page.limit, page.starting_after)
|
|
@@ -8,7 +8,7 @@ from tilebox.workflows.data import (
|
|
|
8
8
|
Job,
|
|
9
9
|
QueryFilters,
|
|
10
10
|
QueryJobsResponse,
|
|
11
|
-
|
|
11
|
+
TaskSubmissions,
|
|
12
12
|
uuid_to_uuid_message,
|
|
13
13
|
)
|
|
14
14
|
from tilebox.workflows.formatting.job import JobWidget, RichDisplayJob
|
|
@@ -39,9 +39,9 @@ class JobService:
|
|
|
39
39
|
"""
|
|
40
40
|
self.service = with_pythonic_errors(JobServiceStub(channel))
|
|
41
41
|
|
|
42
|
-
def submit(self, job_name: str, trace_parent: str, tasks:
|
|
42
|
+
def submit(self, job_name: str, trace_parent: str, tasks: TaskSubmissions) -> Job:
|
|
43
43
|
request = SubmitJobRequest(
|
|
44
|
-
tasks=
|
|
44
|
+
tasks=tasks.to_message(),
|
|
45
45
|
job_name=job_name,
|
|
46
46
|
trace_parent=trace_parent,
|
|
47
47
|
)
|
|
@@ -34,7 +34,13 @@ from tilebox.workflows.observability.logging import get_logger
|
|
|
34
34
|
from tilebox.workflows.observability.tracing import WorkflowTracer
|
|
35
35
|
from tilebox.workflows.runner.task_service import TaskService
|
|
36
36
|
from tilebox.workflows.task import ExecutionContext as ExecutionContextBase
|
|
37
|
-
from tilebox.workflows.task import
|
|
37
|
+
from tilebox.workflows.task import (
|
|
38
|
+
FutureTask,
|
|
39
|
+
ProgressUpdate,
|
|
40
|
+
RunnerContext,
|
|
41
|
+
TaskMeta,
|
|
42
|
+
merge_future_tasks_to_submissions,
|
|
43
|
+
)
|
|
38
44
|
from tilebox.workflows.task import Task as TaskInstance
|
|
39
45
|
|
|
40
46
|
# The time we give a task to finish it's execution when a runner shutdown is requested before we forcefully stop it
|
|
@@ -489,10 +495,12 @@ class TaskRunner:
|
|
|
489
495
|
computed_task = ComputedTask(
|
|
490
496
|
id=task.id,
|
|
491
497
|
display=task.display,
|
|
492
|
-
sub_tasks=
|
|
493
|
-
|
|
494
|
-
for
|
|
495
|
-
|
|
498
|
+
sub_tasks=merge_future_tasks_to_submissions(
|
|
499
|
+
context._sub_tasks, # noqa: SLF001
|
|
500
|
+
# if not otherwise specified, we use the cluster of the runner for all subtasks, which is also
|
|
501
|
+
# the cluster of the parent task
|
|
502
|
+
self.tasks_to_run.cluster_slug,
|
|
503
|
+
),
|
|
496
504
|
progress_updates=_finalize_mutable_progress_trackers(context._progress_indicators), # noqa: SLF001
|
|
497
505
|
)
|
|
498
506
|
|
|
@@ -518,15 +526,30 @@ class ExecutionContext(ExecutionContextBase):
|
|
|
518
526
|
def submit_subtask(
|
|
519
527
|
self,
|
|
520
528
|
task: TaskInstance,
|
|
521
|
-
depends_on: list[FutureTask] | None = None,
|
|
529
|
+
depends_on: FutureTask | list[FutureTask] | None = None,
|
|
522
530
|
cluster: str | None = None,
|
|
523
531
|
max_retries: int = 0,
|
|
524
532
|
) -> FutureTask:
|
|
533
|
+
dependencies: list[int] = []
|
|
534
|
+
|
|
535
|
+
if depends_on is None:
|
|
536
|
+
depends_on = []
|
|
537
|
+
elif isinstance(depends_on, FutureTask):
|
|
538
|
+
depends_on = [depends_on]
|
|
539
|
+
elif not isinstance(depends_on, list):
|
|
540
|
+
raise TypeError(f"Invalid dependency. Expected FutureTask or list[FutureTask], got {type(depends_on)}")
|
|
541
|
+
|
|
542
|
+
for dep in depends_on:
|
|
543
|
+
if not isinstance(dep, FutureTask):
|
|
544
|
+
raise TypeError(f"Invalid dependency. Expected FutureTask, got {type(dep)}")
|
|
545
|
+
if dep.index >= len(self._sub_tasks):
|
|
546
|
+
raise ValueError(f"Dependent task {dep.index} does not exist")
|
|
547
|
+
dependencies.append(dep.index)
|
|
525
548
|
subtask = FutureTask(
|
|
526
549
|
index=len(self._sub_tasks),
|
|
527
550
|
task=task,
|
|
528
551
|
# cyclic dependencies are not allowed, they are detected by the server and will result in an error
|
|
529
|
-
depends_on=
|
|
552
|
+
depends_on=dependencies,
|
|
530
553
|
cluster=cluster,
|
|
531
554
|
max_retries=max_retries,
|
|
532
555
|
)
|
|
@@ -534,9 +557,15 @@ class ExecutionContext(ExecutionContextBase):
|
|
|
534
557
|
return subtask
|
|
535
558
|
|
|
536
559
|
def submit_subtasks(
|
|
537
|
-
self,
|
|
560
|
+
self,
|
|
561
|
+
tasks: Sequence[TaskInstance],
|
|
562
|
+
cluster: str | None = None,
|
|
563
|
+
max_retries: int = 0,
|
|
564
|
+
depends_on: FutureTask | list[FutureTask] | None = None,
|
|
538
565
|
) -> list[FutureTask]:
|
|
539
|
-
return [
|
|
566
|
+
return [
|
|
567
|
+
self.submit_subtask(task, cluster=cluster, max_retries=max_retries, depends_on=depends_on) for task in tasks
|
|
568
|
+
]
|
|
540
569
|
|
|
541
570
|
def submit_batch(
|
|
542
571
|
self, tasks: Sequence[TaskInstance], cluster: str | None = None, max_retries: int = 0
|