Compare commits

131 Commits

Author SHA1 Message Date
allegroai
4684fd5b74 Version bump to v1.13.0 2023-11-17 09:49:26 +02:00
allegroai
e08123fcc0 Fix workers.activity_report should return 0s for the time when no workers reported 2023-11-17 09:49:18 +02:00
allegroai
e713e876eb Upgrade urllib3 requirement 2023-11-17 09:48:19 +02:00
allegroai
c2cc788319 Added supported API versions doc 2023-11-17 09:47:44 +02:00
allegroai
da8315d0db Allow queries on the list of execution queue ids in tasks.get_all/get_all_ex 2023-11-17 09:47:19 +02:00
allegroai
4ac6f88278 Optimize Workers retrieval
Store worker statistics under worker id and not internal redis key
Fix unit tests
2023-11-17 09:46:44 +02:00
allegroai
a7865ccbec Turn on async task events deletion in case there are more than 100_000 events 2023-11-17 09:45:55 +02:00
allegroai
ec14f327c6 Optimize endpoints that do not require authorization by not validating JWT token 2023-11-17 09:45:22 +02:00
allegroai
a03b24d6b6 Add log info on caller IP if token validation fails 2023-11-17 09:43:59 +02:00
allegroai
cb71ef8e47 Fix missing scroll_id in events.get_scalar_metric_data 2023-11-17 09:43:11 +02:00
allegroai
8678fbc995 Fix properly unset Task fields on task reset 2023-11-17 09:42:39 +02:00
allegroai
58df8f201a Update API to 2.27 2023-11-17 09:40:34 +02:00
allegroai
f4bf16c156 Fix schema for swagger compatibility 2023-11-17 09:39:52 +02:00
allegroai
942f996237 Fix async_delete cannot be configured using configuration files 2023-11-17 09:39:22 +02:00
allegroai
c1e7f8f9c1 Optimize deletion of projects with many tasks 2023-11-17 09:38:32 +02:00
allegroai
274c487b37 Add update_tags api to tasks and models 2023-11-17 09:37:25 +02:00
allegroai
cc0129a800 Add filters parameter for passing user defined list filters for all get_all_ex apis 2023-11-17 09:36:58 +02:00
allegroai
388dd1b01f Fix regression issue with archive tasks display 2023-11-17 09:35:55 +02:00
allegroai
d62ecb5e6e Add last_change and last_change_by DB Model 2023-11-17 09:35:22 +02:00
allegroai
6d507616b3 Add pattern parameter to projects.get_hyperparam_values 2023-11-17 09:34:13 +02:00
allegroai
d0252a6dd9 Make sure that hyperparam/configuration/metadata keys that are contain only empty space are rejected 2023-11-17 09:32:22 +02:00
allegroai
2263e7cc1e Fix regression with archive tasks display 2023-07-31 14:16:08 +03:00
allegroai
81b93e6811 Updated dependency - dnspython is a required dependency of pymongo as of pymongo v4.3 (https://pymongo.readthedocs.io/en/stable/changelog.html#changes-in-version-4-3-4-3-2) 2023-07-27 11:49:40 +03:00
allegroai
491e83d0f1 Version bump to v1.12.0 2023-07-26 18:56:04 +03:00
allegroai
f84cc0a2cb Remove 10 metrics limit in multi-task plot comparison 2023-07-26 18:55:49 +03:00
allegroai
6c5f966ed4 Add new_status field to tasks.dequeue and dequeue_many endpoints 2023-07-26 18:55:05 +03:00
allegroai
4eff657810 Fix debug images not returned for tasks in new db 2023-07-26 18:54:19 +03:00
allegroai
74acaa31df Add explicit refresh interval to ES mappings
Fix queue tests
2023-07-26 18:54:02 +03:00
allegroai
21ed8559bf Fix worker keys not returned in queues.get_all_ex 2023-07-26 18:51:20 +03:00
allegroai
3927604648 Add task names to events.get_single_value_metrics endpoint response 2023-07-26 18:50:53 +03:00
allegroai
f7dcbd96ec Fix deleting model events
Add delete_external_artifacts parameter to projects.delete endpoint
2023-07-26 18:49:54 +03:00
allegroai
5950b81f0b Fix child tasks count for top level pipeline and dataset projects 2023-07-26 18:49:12 +03:00
allegroai
1e51e2e221 Allow projection of more than 500 items 2023-07-26 18:46:58 +03:00
allegroai
4c98b87554 Fix issues with new dependencies 2023-07-26 18:46:28 +03:00
allegroai
c196043d2a Add max_download_items to users.get_current_user endpoint response 2023-07-26 18:45:42 +03:00
allegroai
752020c66a Update API version to 2.26 2023-07-26 18:44:20 +03:00
allegroai
6885d07462 Write UTF-8 BOM into csv download file 2023-07-26 18:43:38 +03:00
allegroai
00552da1b0 Requests context is not needed any more 2023-07-26 18:43:09 +03:00
allegroai
eebe2eeffc Update requirements 2023-07-26 18:42:26 +03:00
allegroai
bc2fe28bdd Add field_mappings to organizations download endpoints 2023-07-26 18:39:41 +03:00
allegroai
ed86750b24 Add scalar field type to jsonmodels 2023-07-26 18:39:06 +03:00
allegroai
6df69afb25 Support "__$or" condition on projects children filtering 2023-07-26 18:38:41 +03:00
allegroai
3f22423c3f Support paging in projects.get_model_metadata_values and get_hyperparam_values endpoints 2023-07-26 18:38:11 +03:00
allegroai
3ad636c468 Exported csv file name now contains the project name (including non-ascii names) 2023-07-26 18:37:20 +03:00
allegroai
5c80336aa9 Project delete and validate_delete now analyses and presents info for datasets and pipelines 2023-07-26 18:36:45 +03:00
allegroai
5cd59ea6e3 Fix csv export handling "," in fields 2023-07-26 18:35:31 +03:00
allegroai
5d3ba4fa73 Fix events.get_multitask_plots to retrieve last iterations per each task metric separately 2023-07-26 18:34:30 +03:00
allegroai
42556c8dbb Pipelines children query now looks for pipeline projects and not tasks 2023-07-26 18:33:35 +03:00
allegroai
dbe1c6f00f Allow configuring multi-plots batch size 2023-07-26 18:33:10 +03:00
allegroai
a17485b1bd Allow dequeueing a deleted task 2023-07-26 18:32:32 +03:00
allegroai
a2b9fed92d Make sure that scroll parameters are ignored when downloading tasks 2023-07-26 18:31:56 +03:00
allegroai
ff34da3c88 Add organization.download_for_get_all endpoint 2023-07-26 18:31:20 +03:00
allegroai
5239755066 Support include_subprojects flag in reports.get_all_ex endpoint 2023-07-26 18:30:34 +03:00
allegroai
8061dfedbb Fix NewListBucketsHelper backwards compatibility 2023-07-26 18:27:51 +03:00
allegroai
011164ce9b Support __$and condition for excluded terms in get_all_ex endpoints list filters 2023-07-26 18:26:49 +03:00
allegroai
8135cf5258 Add include_subprojects to tasks/models.get_all endpoints
Fix escaping metadata for tasks, models and queues
2023-07-26 18:24:49 +03:00
allegroai
a83a932e84 Add pipelines.delete_runs endpoint 2023-07-26 18:23:05 +03:00
allegroai
db021f2863 Add workers.get_count endpoint 2023-07-26 18:21:52 +03:00
allegroai
1b650b1689 Add projects.get_user_names endpoint 2023-07-26 18:21:16 +03:00
allegroai
14d18a7aba Remove obsolete duration field 2023-07-26 18:19:41 +03:00
Olivier Girardot
a7ed46979f Fix handling of the subpaths with nginx templating (#204)
Co-authored-by: ogirardot <olivier.girardot@malt.com>
2023-07-02 16:12:29 +03:00
allegroai
452f606889 Version bump to v1.11 2023-05-25 19:40:07 +03:00
allegroai
fc47ccbf09 Add default services agent user 2023-05-25 19:39:53 +03:00
allegroai
0206811342 Improve empty database check during startup 2023-05-25 19:39:17 +03:00
allegroai
a3ac1049a3 Update ClearML SDK dependency 2023-05-25 19:38:48 +03:00
allegroai
8488f63a3a Add fileserver URL prefixes for async deletion 2023-05-25 19:38:07 +03:00
allegroai
9206a7c57d Schedule external file URLs for deletion on models deletion 2023-05-25 19:36:28 +03:00
allegroai
0c37ced2a1 Fix model Id handling when deleting models for tasks 2023-05-25 19:35:18 +03:00
allegroai
b22f26129e Update requirements 2023-05-25 19:34:19 +03:00
allegroai
d8b998ebd8 Bump API version to 2.25 2023-05-25 19:33:37 +03:00
allegroai
741fa84b52 Fix projects own_tasks does not take task state filter into account 2023-05-25 19:32:52 +03:00
allegroai
d9579891c8 Return only reports from the .reports projects in reports.get_all_ex 2023-05-25 19:31:05 +03:00
allegroai
900414d0de Add option to echo ping payload 2023-05-25 19:30:13 +03:00
allegroai
5449b332d2 Support reports from the root project in reports.get_all_ex 2023-05-25 19:29:46 +03:00
allegroai
875f4b9536 Fix task dequeue will changes status for un-queued/running tasks 2023-05-25 19:28:49 +03:00
allegroai
95b8f22899 Add CLEARML_FILES_HOST to async_delete in windows 2023-05-25 19:27:40 +03:00
allegroai
4058fb9ce5 Migrate to python 3.9 bullseye docker images
Update Mongo driver version
2023-05-25 19:27:14 +03:00
allegroai
cf8e847ed3 Switch to new redis version 2023-05-25 19:22:39 +03:00
allegroai
755cc803d9 Add remove_from_all_queues parameter to tasks.dequeue/dequeue_many endpoints 2023-05-25 19:22:10 +03:00
allegroai
3729afe014 Optimize queues.get_next_task to retrieve required task fields only 2023-05-25 19:21:24 +03:00
allegroai
dff2ed34e8 Support receiving mixed events for both locked and unlocked tasks and models events.add_batch 2023-05-25 19:20:35 +03:00
allegroai
de9651d761 Allow mixing Model and task events in the same events batch 2023-05-25 19:19:45 +03:00
allegroai
818496236b Support filtering by children tags in projects.get_all_ex 2023-05-25 19:19:10 +03:00
allegroai
e99817b28b Task reports can now return single value metrics 2023-05-25 19:18:24 +03:00
allegroai
58465fbc17 Model events are fully supported 2023-05-25 19:17:40 +03:00
allegroai
2e4e060a82 Task move forward/backwards in queue is now atomic 2023-05-25 19:16:33 +03:00
allegroai
5c5d9b6434 Fix numeric hyperparam values are not sorted lexicographically with descending sort order 2023-05-25 19:15:59 +03:00
allegroai
4291ad682a Support filtering by task name in projects.get_task_parent 2023-05-25 19:15:26 +03:00
allegroai
4c22757002 Fix task that is not in queue but has 'queued' status can't be dequeued 2023-05-25 19:14:25 +03:00
allegroai
6e777e80b8 Cleaned up unit tests 2023-05-25 19:13:10 +03:00
allegroai
c8e4d9eeac Fix Dockerfile uses deprecated base image 2023-04-18 10:50:13 +03:00
dependabot[bot]
b51aa5c29b Bump redis from 3.5.3 to 4.4.4 in /apiserver (#190)
Bumps [redis](https://github.com/redis/redis-py) from 3.5.3 to 4.4.4.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/3.5.3...v4.4.4)

---
updated-dependencies:
- dependency-name: redis
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-01 08:59:02 +03:00
allegroai
e7c9daa42b Fix get_task_events to correctly use last_iters for model events 2023-03-28 16:45:44 +03:00
allegroai
7357654249 Version bump to v1.10 2023-03-23 19:17:00 +02:00
allegroai
a6f671b46a Fix typo 2023-03-23 19:16:38 +02:00
allegroai
17a8b440bd Fix only last event of each type is stored per model (all should be stored) 2023-03-23 19:16:30 +02:00
allegroai
eb2b9cbd9a Fix project count for datasets and pipelines 2023-03-23 19:15:42 +02:00
allegroai
797e503e67 Update ES version 2023-03-23 19:14:33 +02:00
allegroai
30cfdac8f2 Fix project preview completed_tasks_24h should not count tasks that are marked as failed or running 2023-03-23 19:13:52 +02:00
allegroai
24bb87aaee Turn on mongo sorting using disk usage by default for sorting in *.get_all* apis 2023-03-23 19:12:52 +02:00
allegroai
dd49ba180a Improve statistics on projects children 2023-03-23 19:11:45 +02:00
allegroai
bda903d0d8 Set API version to 2.24 2023-03-23 19:11:13 +02:00
allegroai
9739eb2d5a Add report_assets field to report tasks 2023-03-23 19:09:03 +02:00
allegroai
cfbb37238f Add default workers timeout to the server's configuration 2023-03-23 19:08:11 +02:00
allegroai
6664c6237e Support querying by children_type in projects.get_all_ex 2023-03-23 19:07:42 +02:00
allegroai
74200a24bd Add filtering on child projects in projects.get_all_ex 2023-03-23 19:06:49 +02:00
john-zielke-snkeos
2fb9288a6c Add env switch to disable nginx ipv6 bind (#165) 2023-03-13 16:05:43 +02:00
shyallegro
5d014d81af Fix #184 and update docker build to include widgets (#185) 2023-03-07 11:26:12 +02:00
allegroai
3a2675abe1 Version bump to v1.9.2 2023-01-24 16:11:21 +02:00
allegroai
f0d68b1ce9 Make sure model label values are integer 2023-01-24 16:11:12 +02:00
allegroai
15db9cdaef Allow updating comments on published reports 2023-01-24 14:40:32 +02:00
Mal Miller
a45d47f5d7 Fix default value of CLEARML_AGENT_UPDATE_VERSION for agent-services (#114) 2023-01-03 13:45:52 +02:00
allegroai
b1a50c1370 Version bump to v1.9.1 2023-01-03 12:16:07 +02:00
allegroai
22a2a02760 Allow renaming published reports 2023-01-03 12:15:44 +02:00
allegroai
ab798e4170 Allow updating tags on published reports 2023-01-03 12:15:02 +02:00
allegroai
f09ac672d2 Add pipeline test 2023-01-03 12:14:12 +02:00
allegroai
2149b76f63 Fix crash when starting pipeline 2023-01-03 12:13:48 +02:00
allegroai
d96420aa67 Version bump to v1.9 2022-12-21 18:47:03 +02:00
allegroai
ed6c7b7bcb Fix Project time is not updated when moved or merged 2022-12-21 18:46:53 +02:00
allegroai
a392bc0bd7 Bump API version to 2.23 2022-12-21 18:46:12 +02:00
allegroai
7e97ec5555 Fix events.get_task_plots endpoint 2022-12-21 18:45:17 +02:00
allegroai
9c41124b81 Add support for moving objects to projects root 2022-12-21 18:43:45 +02:00
allegroai
14ff639bb0 Removed limit on event comparison for the same company tasks only 2022-12-21 18:42:40 +02:00
allegroai
e66257761a Add support for server-side delete for AWS S3, Google Storage and Azure Blob Storage 2022-12-21 18:41:16 +02:00
allegroai
0ffde24dc2 Add min and max value iteration to last metrics 2022-12-21 18:36:50 +02:00
allegroai
d4fdcd9b32 Upgrade mongoengine version 2022-12-21 18:35:23 +02:00
allegroai
18570bfccb Add project_id response field to reports.create endpoint 2022-12-21 18:35:14 +02:00
allegroai
54ce6c34c6 Fix bad field values might cause ugly server exception to be returned 2022-12-21 18:33:28 +02:00
allegroai
ae4c33fa0e Add support for allow_public flag in get_all_ex endpoint
Add `last_changed_by` field on task updates
Fix reports support
2022-12-21 18:32:56 +02:00
allegroai
c7cd949fd0 Add reports support
Fix schema
2022-12-21 18:30:54 +02:00
allegroai
1ce4058157 Change tasks comparison limit to 100 2022-12-21 18:29:49 +02:00
143 changed files with 8070 additions and 2627 deletions

View File

@@ -27,7 +27,7 @@
24: ["not_public_object", "object is not public"]
# Auth / Login
75: ["invalid_access_key", "access key not found for user"]
75: ["invalid_access_key", "access key not found"]
# Tasks
100: ["task_error", "general task error"]
@@ -50,6 +50,12 @@
130: ["task_not_found", "task not found"]
131: ["events_not_added", "events not added"]
# Reports
150: ["operation_supported_on_reports_only", "passed task is not report"]
# Pipelines
160: ["cannot_remove_all_runs", "at least one pipeline run should be left"]
# Models
200: ["model_error", "general task error"]
201: ["invalid_model_id", "invalid model id"]
@@ -70,12 +76,14 @@
402: ["project_has_tasks", "project has associated tasks"]
403: ["project_not_found", "project not found"]
405: ["project_has_models", "project has associated models"]
406: ["project_has_datasets", "project has associated non-empty datasets"]
407: ["invalid_project_name", "invalid project name"]
408: ["cannot_update_project_location", "Cannot update project location. Use projects.move instead"]
409: ["project_path_exceeds_max", "Project path exceed the maximum allowed depth"]
410: ["project_source_and_destination_are_the_same", "Project has the same source and destination paths"]
411: ["project_cannot_be_moved_under_itself", "Project can not be moved under itself in the projects hierarchy"]
412: ["project_cannot_be_merged_into_its_child", "Project can not be merged into its own child"]
413: ["project_has_pipelines", "project has associated pipelines with active controllers"]
# Queues
701: ["invalid_queue_id", "invalid queue id"]

View File

@@ -61,6 +61,13 @@ class ListField(fields.ListField):
item.validate()
class ScalarField(fields.BaseField):
"""String field."""
types = (str, int, float, bool)
class DictField(fields.BaseField):
types = (dict,)

View File

@@ -36,7 +36,7 @@ class MultiTaskScalarMetricsIterHistogramRequest(HistogramRequestBase):
Length(
minimum_value=1,
maximum_value=config.get(
"services.tasks.multi_task_histogram_limit", 10
"services.tasks.multi_task_histogram_limit", 100
),
)
],
@@ -155,6 +155,13 @@ class TaskMetricsRequest(MultiTasksRequestBase):
event_type: EventType = ActualEnumField(EventType, required=True)
class MultiTaskPlotsRequest(MultiTasksRequestBase):
iters: int = IntField(default=1)
scroll_id: str = StringField()
no_scroll: bool = BoolField(default=False)
last_iters_per_task_metric: bool = BoolField(default=True)
class TaskPlotsRequest(Base):
task: str = StringField(required=True)
iters: int = IntField(default=1)

View File

@@ -44,10 +44,12 @@ class ModelRequest(models.Base):
class DeleteModelRequest(ModelRequest):
force = fields.BoolField(default=False)
delete_external_artifacts = fields.BoolField(default=True)
class ModelsDeleteManyRequest(BatchRequest):
force = fields.BoolField(default=False)
delete_external_artifacts = fields.BoolField(default=True)
class PublishModelRequest(ModelRequest):
@@ -79,3 +81,4 @@ class AddOrUpdateMetadataRequest(AddOrUpdateMetadata):
class ModelsGetRequest(models.Base):
include_stats = fields.BoolField(default=False)
allow_public = fields.BoolField(default=True)

View File

@@ -1,6 +1,11 @@
from jsonmodels import fields, models
from enum import auto
from typing import Sequence
from apiserver.apimodels import DictField
from jsonmodels import fields, models
from jsonmodels.validators import Length
from apiserver.apimodels import DictField, ActualEnumField, ScalarField
from apiserver.utilities.stringenum import StringEnum
class Filter(models.Base):
@@ -19,5 +24,39 @@ class EntitiesCountRequest(models.Base):
models = DictField()
pipelines = DictField()
datasets = DictField()
reports = DictField()
active_users = fields.ListField(str)
search_hidden = fields.BoolField(default=False)
allow_public = fields.BoolField(default=True)
class EntityType(StringEnum):
task = auto()
model = auto()
class ValueMapping(models.Base):
key = ScalarField(nullable=True)
value = ScalarField(nullable=True)
class FieldMapping(models.Base):
field = fields.StringField(required=True)
name = fields.StringField()
values: Sequence[ValueMapping] = fields.ListField(items_types=[ValueMapping])
class PrepareDownloadForGetAllRequest(models.Base):
entity_type = ActualEnumField(EntityType)
allow_public = fields.BoolField(default=True)
search_hidden = fields.BoolField(default=False)
only_fields = fields.ListField(
items_types=[str], validators=[Length(1)], required=True
)
field_mappings: Sequence[FieldMapping] = fields.ListField(
items_types=[FieldMapping], validators=[Length(1)], required=True
)
class DownloadForGetAllRequest(models.Base):
prepare_id = fields.StringField(required=True)

View File

@@ -1,4 +1,5 @@
from jsonmodels import models, fields
from jsonmodels.validators import Length
from apiserver.apimodels import ListField
@@ -8,6 +9,11 @@ class Arg(models.Base):
value = fields.StringField(required=True)
class DeleteRunsRequest(models.Base):
project = fields.StringField(required=True)
ids = ListField([str], required=True, validators=[Length(1)])
class StartPipelineRequest(models.Base):
task = fields.StringField(required=True)
queue = fields.StringField(required=True)

View File

@@ -1,8 +1,11 @@
from enum import Enum, auto
from jsonmodels import models, fields
from apiserver.apimodels import ListField, ActualEnumField, DictField
from apiserver.apimodels.organization import TagsRequest
from apiserver.database.model import EntityVisibility
from apiserver.utilities.stringenum import StringEnum
class ProjectRequest(models.Base):
@@ -20,6 +23,7 @@ class MoveRequest(ProjectRequest):
class DeleteRequest(ProjectRequest):
force = fields.BoolField(default=False)
delete_contents = fields.BoolField(default=False)
delete_external_artifacts = fields.BoolField(default=True)
class ProjectOrNoneRequest(models.Base):
@@ -27,6 +31,10 @@ class ProjectOrNoneRequest(models.Base):
include_subprojects = fields.BoolField(default=True)
class GetUniqueMetricsRequest(ProjectOrNoneRequest):
model_metrics = fields.BoolField(default=False)
class GetParamsRequest(ProjectOrNoneRequest):
page = fields.IntField(default=0)
page_size = fields.IntField(default=500)
@@ -43,17 +51,38 @@ class MultiProjectRequest(models.Base):
class ProjectTaskParentsRequest(MultiProjectRequest):
tasks_state = ActualEnumField(EntityVisibility)
task_name = fields.StringField()
class ProjectHyperparamValuesRequest(MultiProjectRequest):
class EntityTypeEnum(StringEnum):
task = auto()
model = auto()
class ProjectUserNamesRequest(MultiProjectRequest):
entity = ActualEnumField(EntityTypeEnum, default=EntityTypeEnum.task)
class MultiProjectPagedRequest(MultiProjectRequest):
allow_public = fields.BoolField(default=True)
page = fields.IntField(default=0)
page_size = fields.IntField(default=500)
class ProjectHyperparamValuesRequest(MultiProjectPagedRequest):
section = fields.StringField(required=True)
name = fields.StringField(required=True)
allow_public = fields.BoolField(default=True)
pattern = fields.StringField()
class ProjectModelMetadataValuesRequest(MultiProjectRequest):
class ProjectModelMetadataValuesRequest(MultiProjectPagedRequest):
key = fields.StringField(required=True)
allow_public = fields.BoolField(default=True)
class ProjectChildrenType(Enum):
pipeline = "pipeline"
report = "report"
dataset = "dataset"
class ProjectsGetRequest(models.Base):
@@ -62,8 +91,12 @@ class ProjectsGetRequest(models.Base):
include_stats_filter = DictField()
stats_with_children = fields.BoolField(default=True)
stats_for_state = ActualEnumField(EntityVisibility, default=EntityVisibility.active)
non_public = fields.BoolField(default=False)
non_public = fields.BoolField(default=False) # legacy, use allow_public instead
active_users = fields.ListField(str)
check_own_contents = fields.BoolField(default=False)
shallow_search = fields.BoolField(default=False)
search_hidden = fields.BoolField(default=False)
allow_public = fields.BoolField(default=True)
children_type = ActualEnumField(ProjectChildrenType)
children_tags = fields.ListField(str)
children_tags_filter = DictField()

View File

@@ -0,0 +1,84 @@
from typing import Sequence
from jsonmodels import validators
from jsonmodels.fields import StringField, ListField, BoolField, EmbeddedField, IntField
from jsonmodels.models import Base
from jsonmodels.validators import Length
from apiserver.apimodels.events import MetricVariants, HistogramRequestBase
class UpdateReportRequest(Base):
task = StringField(required=True)
name = StringField(nullable=True, validators=Length(minimum_value=3))
tags = ListField(items_types=[str])
comment = StringField()
report = StringField()
report_assets = ListField(items_types=[str])
class CreateReportRequest(Base):
name = StringField(required=True, validators=Length(minimum_value=3))
tags = ListField(items_types=[str])
comment = StringField()
report = StringField()
project = StringField()
report_assets = ListField(items_types=[str])
class PublishReportRequest(Base):
task = StringField(required=True)
message = StringField(default="")
class ArchiveReportRequest(Base):
task = StringField(required=True)
message = StringField(default="")
class ShareReportRequest(Base):
task = StringField(required=True)
share = BoolField(default=True)
class DeleteReportRequest(Base):
task = StringField(required=True)
force = BoolField(default=False)
class MoveReportRequest(Base):
task = StringField(required=True)
project = StringField()
project_name = StringField()
class EventsRequest(Base):
iters = IntField(default=1, validators=validators.Min(1))
metrics: Sequence[MetricVariants] = ListField(items_types=MetricVariants)
class PlotEventsRequest(EventsRequest):
last_iters_per_task_metric: bool = BoolField(default=True)
class ScalarMetricsIterHistogram(HistogramRequestBase):
metrics: Sequence[MetricVariants] = ListField(items_types=MetricVariants)
class SingleValueMetrics(Base):
pass
class GetTasksDataRequest(Base):
debug_images: EventsRequest = EmbeddedField(EventsRequest)
plots: PlotEventsRequest = EmbeddedField(PlotEventsRequest)
scalar_metrics_iter_histogram: ScalarMetricsIterHistogram = EmbeddedField(
ScalarMetricsIterHistogram
)
single_value_metrics: SingleValueMetrics = EmbeddedField(SingleValueMetrics)
allow_public = BoolField(default=True)
model_events: bool = BoolField(default=False)
class GetAllRequest(Base):
allow_public = BoolField(default=True)

View File

@@ -96,6 +96,11 @@ class UpdateRequest(TaskUpdateRequest):
status_message = StringField(default="")
class DequeueRequest(UpdateRequest):
remove_from_all_queues = BoolField(default=False)
new_status = StringField()
class EnqueueRequest(UpdateRequest):
queue = StringField()
queue_name = StringField()
@@ -274,6 +279,11 @@ class StopManyRequest(TaskBatchRequest):
force = BoolField(default=False)
class DequeueManyRequest(TaskBatchRequest):
remove_from_all_queues = BoolField(default=False)
new_status = StringField()
class EnqueueManyRequest(TaskBatchRequest):
queue = StringField()
queue_name = StringField()
@@ -318,3 +328,13 @@ class DeleteModelsRequest(TaskRequest):
models: Sequence[ModelItemKey] = ListField(
[ModelItemKey], validators=Length(minimum_value=1)
)
class GetAllReq(models.Base):
allow_public = BoolField(default=True)
search_hidden = BoolField(default=False)
class UpdateTagsRequest(BatchRequest):
add_tags = ListField([str])
remove_tags = ListField([str])

View File

@@ -12,9 +12,8 @@ from jsonmodels.fields import (
)
from jsonmodels.models import Base
from apiserver.apimodels import make_default, ListField, EnumField, JsonSerializableMixin
DEFAULT_TIMEOUT = 10 * 60
from apiserver.apimodels import ListField, EnumField, JsonSerializableMixin
from apiserver.config_repo import config
class WorkerRequest(Base):
@@ -24,9 +23,10 @@ class WorkerRequest(Base):
class RegisterRequest(WorkerRequest):
timeout = make_default(
IntField, DEFAULT_TIMEOUT
)() # registration timeout in seconds (default is 10min)
timeout = IntField(
default=int(config.get("services.workers.default_worker_timeout_sec", 10 * 60))
)
""" registration timeout in seconds (default is 10min) """
queues = ListField(six.string_types) # list of queues this worker listens to
@@ -106,6 +106,10 @@ class GetAllResponse(Base):
workers = ListField(WorkerResponseEntry)
class GetCountRequest(GetAllRequest):
last_seen = IntField(default=0)
class StatsBase(Base):
worker_ids = ListField(str)

View File

@@ -5,10 +5,10 @@ import zlib
from collections import defaultdict
from contextlib import closing
from datetime import datetime
from operator import attrgetter
from typing import Sequence, Set, Tuple, Optional, List, Mapping, Union
import elasticsearch
from boltons.iterutils import chunked_iter
from elasticsearch.helpers import BulkIndexError
from mongoengine import Q
from nested_dict import nested_dict
@@ -23,12 +23,14 @@ from apiserver.bll.event.event_common import (
get_metric_variants_condition,
uncompress_plot,
get_max_metric_and_variant_counts,
PlotFields,
)
from apiserver.bll.event.events_iterator import EventsIterator, TaskEventsResult
from apiserver.bll.event.history_debug_image_iterator import HistoryDebugImageIterator
from apiserver.bll.event.history_plots_iterator import HistoryPlotsIterator
from apiserver.bll.event.metric_debug_images_iterator import MetricDebugImagesIterator
from apiserver.bll.event.metric_plots_iterator import MetricPlotsIterator
from apiserver.bll.model import ModelBLL
from apiserver.bll.util import parallel_chunked_decorator
from apiserver.database import utils as dbutils
from apiserver.database.model.model import Model
@@ -45,25 +47,19 @@ from apiserver.utilities.dicts import nested_get
from apiserver.utilities.json import loads
# noinspection PyTypeChecker
EVENT_TYPES: Set[str] = set(map(attrgetter("value"), EventType))
EVENT_TYPES: Set[str] = set(et.value for et in EventType if et != EventType.all)
LOCKED_TASK_STATUSES = (TaskStatus.publishing, TaskStatus.published)
MAX_LONG = 2 ** 63 - 1
MIN_LONG = -(2 ** 63)
MAX_LONG = 2**63 - 1
MIN_LONG = -(2**63)
log = config.logger(__file__)
class PlotFields:
valid_plot = "valid_plot"
plot_len = "plot_len"
plot_str = "plot_str"
plot_data = "plot_data"
source_urls = "source_urls"
async_task_events_delete = config.get("services.tasks.async_events_delete", False)
async_delete_threshold = config.get("services.tasks.async_events_delete_threshold", 100_000)
class EventBLL(object):
id_fields = ("task", "iter", "metric", "variant", "key")
event_id_fields = ("task", "iter", "metric", "variant", "key")
empty_scroll = "FFFF"
img_source_regex = re.compile(
r"['\"]source['\"]:\s?['\"]([a-z][a-z0-9+\-.]*://.*?)['\"]",
@@ -101,47 +97,76 @@ class EventBLL(object):
return self._metrics
@staticmethod
def _get_valid_tasks(company_id, task_ids: Set, allow_locked_tasks=False) -> Set:
"""Verify that task exists and can be updated"""
if not task_ids:
def _get_valid_entities(company_id, ids: Mapping[str, bool], model=False) -> Set:
"""Verify that task or model exists and can be updated"""
if not ids:
return set()
with translate_errors_context():
query = Q(id__in=task_ids, company=company_id)
if not allow_locked_tasks:
query &= Q(status__nin=LOCKED_TASK_STATUSES)
res = Task.objects(query).only("id")
return {r.id for r in res}
allow_locked = {id_ for id_, allowed in ids.items() if allowed}
not_locked = {id_ for id_, allowed in ids.items() if not allowed}
res = set()
allow_locked_q = Q()
not_locked_q = (
Q(ready__ne=True) if model else Q(status__nin=LOCKED_TASK_STATUSES)
)
for requested_ids, locked_q in (
(allow_locked, allow_locked_q),
(not_locked, not_locked_q),
):
if not requested_ids:
continue
query = Q(id__in=requested_ids, company=company_id)
res.update(
(Model if model else Task).objects(query & locked_q).scalar("id")
)
@staticmethod
def _get_valid_models(company_id, model_ids: Set, allow_locked_models=False) -> Set:
"""Verify that task exists and can be updated"""
if not model_ids:
return set()
with translate_errors_context():
query = Q(id__in=model_ids, company=company_id)
if not allow_locked_models:
query &= Q(ready__ne=True)
res = Model.objects(query).only("id")
return {r.id for r in res}
return res
def add_events(
self, company_id, events, worker, allow_locked=False
self,
company_id: str,
user_id: str,
events: Sequence[dict],
worker: str,
) -> Tuple[int, int, dict]:
model_events = events[0].get("model_event", False)
task_ids = {}
model_ids = {}
for event in events:
if event.get("model_event", model_events) != model_events:
if event.get("model_event", False):
model = event.pop("model", None)
if model is not None:
event["task"] = model
entity_ids = model_ids
else:
event["model_event"] = False
entity_ids = task_ids
id_ = event.get("task")
allow_locked = event.pop("allow_locked", False)
if not id_:
continue
allowed_for_entity = entity_ids.get(id_)
if allowed_for_entity is None:
entity_ids[id_] = allow_locked
elif allowed_for_entity != allow_locked:
raise errors.bad_request.ValidationError(
"Inconsistent model_event setting in the passed events"
)
if event.pop("allow_locked", allow_locked) != allow_locked:
raise errors.bad_request.ValidationError(
"Inconsistent allow_locked setting in the passed events"
f"Inconsistent allow_locked setting in the passed events for {id_}"
)
found_in_both = set(task_ids).intersection(set(model_ids))
if found_in_both:
raise errors.bad_request.ValidationError(
"Inconsistent model_event setting in the passed events",
tasks=found_in_both,
)
valid_models = self._get_valid_entities(company_id, ids=model_ids, model=True)
valid_tasks = self._get_valid_entities(company_id, ids=task_ids)
actions: List[dict] = []
task_or_model_ids = set()
used_task_ids = set()
used_model_ids = set()
task_iteration = defaultdict(lambda: 0)
task_last_scalar_events = nested_dict(
3, dict
@@ -151,28 +176,6 @@ class EventBLL(object):
) # task_id -> metric_hash -> event_type -> MetricEvent
errors_per_type = defaultdict(int)
invalid_iteration_error = f"Iteration number should not exceed {MAX_LONG}"
if model_events:
for event in events:
model = event.pop("model", None)
if model is not None:
event["task"] = model
valid_entities = self._get_valid_models(
company_id,
model_ids={
event["task"] for event in events if event.get("task") is not None
},
allow_locked_models=allow_locked,
)
entity_name = "model"
else:
valid_entities = self._get_valid_tasks(
company_id,
task_ids={
event["task"] for event in events if event.get("task") is not None
},
allow_locked_tasks=allow_locked,
)
entity_name = "task"
for event in events:
# remove spaces from event type
@@ -186,7 +189,8 @@ class EventBLL(object):
errors_per_type[f"Invalid event type {event_type}"] += 1
continue
if model_events and event_type == EventType.task_log.value:
model_event = event["model_event"]
if model_event and event_type == EventType.task_log.value:
errors_per_type[f"Task log events are not supported for models"] += 1
continue
@@ -195,8 +199,12 @@ class EventBLL(object):
errors_per_type["Event must have a 'task' field"] += 1
continue
if task_or_model_id not in valid_entities:
errors_per_type[f"Invalid {entity_name} id {task_or_model_id}"] += 1
if (model_event and task_or_model_id not in valid_models) or (
not model_event and task_or_model_id not in valid_tasks
):
errors_per_type[
f"Invalid {'model' if model_event else 'task'} id {task_or_model_id}"
] += 1
continue
event["type"] = event_type
@@ -218,13 +226,10 @@ class EventBLL(object):
# force iter to be a long int
iter = event.get("iter")
if iter is not None:
if model_events:
iter = 0
else:
iter = int(iter)
if iter > MAX_LONG or iter < MIN_LONG:
errors_per_type[invalid_iteration_error] += 1
continue
iter = int(iter)
if iter > MAX_LONG or iter < MIN_LONG:
errors_per_type[invalid_iteration_error] += 1
continue
event["iter"] = iter
# used to have "values" to indicate array. no need anymore
@@ -234,7 +239,6 @@ class EventBLL(object):
event["metric"] = event.get("metric") or ""
event["variant"] = event.get("variant") or ""
event["model_event"] = model_events
index_name = get_index_name(company_id, event_type)
es_action = {
@@ -243,31 +247,33 @@ class EventBLL(object):
"_source": event,
}
# for "log" events, don't assing custom _id - whatever is sent, is written (not overwritten)
# for "log" events, don't assign custom _id - whatever is sent, is written (not overwritten)
if event_type != EventType.task_log.value:
es_action["_id"] = self._get_event_id(event)
else:
es_action["_id"] = dbutils.id()
task_or_model_ids.add(task_or_model_id)
if (
iter is not None
and not model_events
and event.get("metric") not in self._skip_iteration_for_metric
):
task_iteration[task_or_model_id] = max(
iter, task_iteration[task_or_model_id]
)
if not model_events:
if model_event:
used_model_ids.add(task_or_model_id)
else:
used_task_ids.add(task_or_model_id)
self._update_last_metric_events_for_task(
last_events=task_last_events[task_or_model_id], event=event,
last_events=task_last_events[task_or_model_id],
event=event,
)
if event_type == EventType.metrics_scalar.value:
self._update_last_scalar_events_for_task(
last_events=task_last_scalar_events[task_or_model_id],
event=event,
)
if event_type == EventType.metrics_scalar.value:
self._update_last_scalar_events_for_task(
last_events=task_last_scalar_events[task_or_model_id],
event=event,
)
actions.append(es_action)
@@ -305,31 +311,40 @@ class EventBLL(object):
else:
errors_per_type["Error when indexing events batch"] += 1
if not model_events:
remaining_tasks = set()
now = datetime.utcnow()
for task_or_model_id in task_or_model_ids:
# Update related tasks. For reasons of performance, we prefer to update
# all of them and not only those who's events were successful
updated = self._update_task(
company_id=company_id,
task_id=task_or_model_id,
now=now,
iter_max=task_iteration.get(task_or_model_id),
last_scalar_events=task_last_scalar_events.get(
task_or_model_id
),
last_events=task_last_events.get(task_or_model_id),
)
now = datetime.utcnow()
for model_id in used_model_ids:
ModelBLL.update_statistics(
company_id=company_id,
user_id=user_id,
model_id=model_id,
last_update=now,
last_iteration_max=task_iteration.get(model_id),
last_scalar_events=task_last_scalar_events.get(model_id),
)
remaining_tasks = set()
for task_id in used_task_ids:
# Update related tasks. For reasons of performance, we prefer to update
# all of them and not only those who's events were successful
updated = self._update_task(
company_id=company_id,
user_id=user_id,
task_id=task_id,
now=now,
iter_max=task_iteration.get(task_id),
last_scalar_events=task_last_scalar_events.get(task_id),
last_events=task_last_events.get(task_id),
)
if not updated:
remaining_tasks.add(task_id)
continue
if not updated:
remaining_tasks.add(task_or_model_id)
continue
if remaining_tasks:
TaskBLL.set_last_update(
remaining_tasks, company_id, last_update=now
)
if remaining_tasks:
TaskBLL.set_last_update(
remaining_tasks,
company_id=company_id,
user_id=user_id,
last_update=now,
)
# this is for backwards compatibility with streaming bulk throwing exception on those
invalid_iterations_count = errors_per_type.get(invalid_iteration_error)
@@ -424,8 +439,22 @@ class EventBLL(object):
for k in ("value", "metric", "variant", "iter", "timestamp")
if k in event
}
event_data["min_value"] = min(value, last_event.get("min_value", value))
event_data["max_value"] = max(value, last_event.get("max_value", value))
last_event_min_value = last_event.get("min_value", value)
last_event_min_value_iter = last_event.get("min_value_iter", event_iter)
if value < last_event_min_value:
event_data["min_value"] = value
event_data["min_value_iter"] = event_iter
else:
event_data["min_value"] = last_event_min_value
event_data["min_value_iter"] = last_event_min_value_iter
last_event_max_value = last_event.get("max_value", value)
last_event_max_value_iter = last_event.get("max_value_iter", event_iter)
if value > last_event_max_value:
event_data["max_value"] = value
event_data["max_value_iter"] = event_iter
else:
event_data["max_value"] = last_event_max_value
event_data["max_value_iter"] = last_event_max_value_iter
last_events[metric_hash][variant_hash] = event_data
def _update_last_metric_events_for_task(self, last_events, event):
@@ -445,9 +474,10 @@ class EventBLL(object):
def _update_task(
self,
company_id,
task_id,
now,
company_id: str,
user_id: str,
task_id: str,
now: datetime,
iter_max=None,
last_scalar_events=None,
last_events=None,
@@ -463,8 +493,9 @@ class EventBLL(object):
return False
return TaskBLL.update_statistics(
task_id,
company_id,
task_id=task_id,
company_id=company_id,
user_id=user_id,
last_update=now,
last_iteration_max=iter_max,
last_scalar_events=last_scalar_events,
@@ -472,7 +503,9 @@ class EventBLL(object):
)
def _get_event_id(self, event):
id_values = (str(event[field]) for field in self.id_fields if field in event)
id_values = (
str(event[field]) for field in self.event_id_fields if field in event
)
return hashlib.md5("-".join(id_values).encode()).hexdigest()
def scroll_task_events(
@@ -516,29 +549,45 @@ class EventBLL(object):
return events, next_scroll_id, total_events
def get_last_iterations_per_event_metric_variant(
def get_task_plots(
self,
company_id: str,
task_id: str,
num_last_iterations: int,
event_type: EventType,
last_iterations_per_plot: int,
metric_variants: MetricVariants = None,
):
if check_empty_data(self.es, company_id=company_id, event_type=event_type):
return []
event_type = EventType.metrics_plot
if check_empty_data(self.es, company_id, event_type):
return TaskEventsResult()
must = [{"term": {"task": task_id}}]
plot_valid_condition = {
"bool": {
"should": [
{"term": {PlotFields.valid_plot: True}},
{
"bool": {
"must_not": {"exists": {"field": PlotFields.valid_plot}}
}
},
]
}
}
must = [plot_valid_condition, {"term": {"task": task_id}}]
if metric_variants:
must.append(get_metric_variants_condition(metric_variants))
query = {"bool": {"must": must}}
search_args = dict(es=self.es, company_id=company_id, event_type=event_type)
max_metrics, max_variants = get_max_metric_and_variant_counts(
query=query, **search_args,
query=query,
**search_args,
)
max_variants = int(max_variants // num_last_iterations)
max_variants = int(max_variants // last_iterations_per_plot)
es_req: dict = {
es_req = {
"sort": [{"iter": {"order": "desc"}}],
"size": 0,
"query": query,
"aggs": {
"metrics": {
"terms": {
@@ -554,11 +603,10 @@ class EventBLL(object):
"order": {"_key": "asc"},
},
"aggs": {
"iters": {
"terms": {
"field": "iter",
"size": num_last_iterations,
"order": {"_key": "desc"},
"events": {
"top_hits": {
"sort": {"iter": {"order": "desc"}},
"size": last_iterations_per_plot,
}
}
},
@@ -566,117 +614,23 @@ class EventBLL(object):
},
}
},
"query": query,
}
with translate_errors_context():
es_res = search_company_events(body=es_req, **search_args)
es_response = search_company_events(body=es_req, ignore=404, **search_args)
if "aggregations" not in es_res:
return []
return [
(metric["key"], variant["key"], iter["key"])
for metric in es_res["aggregations"]["metrics"]["buckets"]
for variant in metric["variants"]["buckets"]
for iter in variant["iters"]["buckets"]
]
def get_task_plots(
self,
company_id: str,
tasks: Sequence[str],
last_iterations_per_plot: int = None,
sort=None,
size: int = 500,
scroll_id: str = None,
no_scroll: bool = False,
metric_variants: MetricVariants = None,
model_events: bool = False,
):
if scroll_id == self.empty_scroll:
aggs_result = es_response.get("aggregations")
if not aggs_result:
return TaskEventsResult()
if scroll_id:
with translate_errors_context():
es_res = self.es.scroll(scroll_id=scroll_id, scroll="1h")
else:
event_type = EventType.metrics_plot
if check_empty_data(self.es, company_id=company_id, event_type=event_type):
return TaskEventsResult()
plot_valid_condition = {
"bool": {
"should": [
{"term": {PlotFields.valid_plot: True}},
{
"bool": {
"must_not": {"exists": {"field": PlotFields.valid_plot}}
}
},
]
}
}
must = [plot_valid_condition]
if last_iterations_per_plot is None or model_events:
must.append({"terms": {"task": tasks}})
if metric_variants:
must.append(get_metric_variants_condition(metric_variants))
else:
should = []
for i, task_id in enumerate(tasks):
last_iters = self.get_last_iterations_per_event_metric_variant(
company_id=company_id,
task_id=task_id,
num_last_iterations=last_iterations_per_plot,
event_type=event_type,
metric_variants=metric_variants,
)
if not last_iters:
continue
for metric, variant, iter in last_iters:
should.append(
{
"bool": {
"must": [
{"term": {"task": task_id}},
{"term": {"metric": metric}},
{"term": {"variant": variant}},
{"term": {"iter": iter}},
]
}
}
)
if not should:
return TaskEventsResult()
must.append({"bool": {"should": should}})
if sort is None:
sort = [{"timestamp": {"order": "asc"}}]
es_req = {
"sort": sort,
"size": min(size, 10000),
"query": {"bool": {"must": must}},
}
with translate_errors_context():
es_res = search_company_events(
self.es,
company_id=company_id,
event_type=event_type,
body=es_req,
ignore=404,
**({} if no_scroll else {"scroll": "1h"}),
)
events, total_events, next_scroll_id = self._get_events_from_es_res(es_res)
events = [
hit["_source"]
for metrics_bucket in aggs_result["metrics"]["buckets"]
for variants_bucket in metrics_bucket["variants"]["buckets"]
for hit in variants_bucket["events"]["hits"]["hits"]
]
self.uncompress_plots(events)
return TaskEventsResult(
events=events, next_scroll_id=next_scroll_id, total_events=total_events
)
return TaskEventsResult(events=events, total_events=len(events))
def _get_events_from_es_res(self, es_res: dict) -> Tuple[list, int, Optional[str]]:
"""
@@ -693,9 +647,11 @@ class EventBLL(object):
return events, total_events, next_scroll_id
def get_debug_image_urls(
self, company_id: str, task_id: str, after_key: dict = None
self, company_id: str, task_ids: Sequence[str], after_key: dict = None
) -> Tuple[Sequence[str], Optional[dict]]:
if check_empty_data(self.es, company_id, EventType.metrics_image):
if not task_ids or check_empty_data(
self.es, company_id, EventType.metrics_image
):
return [], None
es_req = {
@@ -711,7 +667,10 @@ class EventBLL(object):
},
"query": {
"bool": {
"must": [{"term": {"task": task_id}}, {"exists": {"field": "url"}}]
"must": [
{"terms": {"task": task_ids}},
{"exists": {"field": "url"}},
]
}
},
}
@@ -729,9 +688,13 @@ class EventBLL(object):
return [bucket["key"]["url"] for bucket in res["buckets"]], res.get("after_key")
def get_plot_image_urls(
self, company_id: str, task_id: str, scroll_id: Optional[str]
self, company_id: str, task_ids: Sequence[str], scroll_id: Optional[str]
) -> Tuple[Sequence[dict], Optional[str]]:
if scroll_id == self.empty_scroll:
if (
scroll_id == self.empty_scroll
or not task_ids
or check_empty_data(self.es, company_id, EventType.metrics_plot)
):
return [], None
if scroll_id:
@@ -746,7 +709,7 @@ class EventBLL(object):
"query": {
"bool": {
"must": [
{"term": {"task": task_id}},
{"terms": {"task": task_ids}},
{"exists": {"field": PlotFields.source_urls}},
]
}
@@ -765,17 +728,16 @@ class EventBLL(object):
def get_task_events(
self,
company_id: str,
task_id: str,
company_id: Union[str, Sequence[str]],
task_id: Union[str, Sequence[str]],
event_type: EventType,
metric=None,
variant=None,
metrics: MetricVariants = None,
last_iter_count=None,
sort=None,
size=500,
scroll_id=None,
no_scroll=False,
model_events=False,
last_iters_per_task_metric=False,
) -> TaskEventsResult:
if scroll_id == self.empty_scroll:
return TaskEventsResult()
@@ -784,38 +746,65 @@ class EventBLL(object):
with translate_errors_context():
es_res = self.es.scroll(scroll_id=scroll_id, scroll="1h")
else:
if check_empty_data(self.es, company_id=company_id, event_type=event_type):
company_ids = [company_id] if isinstance(company_id, str) else company_id
company_ids = [
c_id
for c_id in set(company_ids)
if not check_empty_data(self.es, c_id, event_type)
]
if not company_ids:
return TaskEventsResult()
task_ids = [task_id] if isinstance(task_id, str) else task_id
must = []
if metric:
must.append({"term": {"metric": metric}})
if variant:
must.append({"term": {"variant": variant}})
if metrics:
must.append(get_metric_variants_condition(metrics))
if last_iter_count is None or model_events:
if last_iter_count is None:
must.append({"terms": {"task": task_ids}})
else:
tasks_iters = self.get_last_iters(
company_id=company_id,
event_type=event_type,
task_id=task_ids,
iters=last_iter_count,
)
should = [
{
"bool": {
"must": [
{"term": {"task": task}},
{"terms": {"iter": last_iters}},
]
if last_iters_per_task_metric:
task_metric_iters = self.get_last_iters_per_metric(
company_id=company_ids,
event_type=event_type,
task_id=task_ids,
iters=last_iter_count,
metrics=metrics,
)
should = [
{
"bool": {
"must": [
{"term": {"task": task}},
{"term": {"metric": metric}},
{"terms": {"iter": last_iters}},
]
}
}
}
for task, last_iters in tasks_iters.items()
if last_iters
]
for (task, metric), last_iters in task_metric_iters.items()
if last_iters
]
else:
tasks_iters = self.get_last_iters(
company_id=company_ids,
event_type=event_type,
task_id=task_ids,
iters=last_iter_count,
metrics=metrics,
)
should = [
{
"bool": {
"must": [
{"term": {"task": task}},
{"terms": {"iter": last_iters}},
]
}
}
for task, last_iters in tasks_iters.items()
if last_iters
]
if not should:
return TaskEventsResult()
must.append({"bool": {"should": should}})
@@ -832,7 +821,7 @@ class EventBLL(object):
with translate_errors_context():
es_res = search_company_events(
self.es,
company_id=company_id,
company_id=company_ids,
event_type=event_type,
body=es_req,
ignore=404,
@@ -856,7 +845,8 @@ class EventBLL(object):
query = {"bool": {"must": [{"term": {"task": task_id}}]}}
search_args = dict(es=self.es, company_id=company_id, event_type=event_type)
max_metrics, max_variants = get_max_metric_and_variant_counts(
query=query, **search_args,
query=query,
**search_args,
)
es_req = {
"size": 0,
@@ -910,8 +900,10 @@ class EventBLL(object):
}
search_args = dict(es=self.es, company_id=company_id, event_type=event_type)
max_metrics, max_variants = get_max_metric_and_variant_counts(
query=query, **search_args,
query=query,
**search_args,
)
max_variants = int(max_variants // 2)
es_req = {
"size": 0,
"query": query,
@@ -1013,22 +1005,98 @@ class EventBLL(object):
return iterations, vectors
def get_last_iters(
def get_last_iters_per_metric(
self,
company_id: str,
company_id: Union[str, Sequence[str]],
event_type: EventType,
task_id: Union[str, Sequence[str]],
iters: int,
) -> Mapping[str, Sequence]:
if check_empty_data(self.es, company_id=company_id, event_type=event_type):
metrics: MetricVariants = None,
) -> Mapping[Tuple[str, str], Sequence]:
company_ids = [company_id] if isinstance(company_id, str) else company_id
company_ids = [
c_id
for c_id in set(company_ids)
if not check_empty_data(self.es, c_id, event_type)
]
if not company_ids:
return {}
task_ids = [task_id] if isinstance(task_id, str) else task_id
must = [{"terms": {"task": task_ids}}]
if metrics:
must.append(get_metric_variants_condition(metrics))
max_tasks = min(len(task_ids), 1000)
max_metrics = 10_000 // (max_tasks * iters)
es_req: dict = {
"size": 0,
"aggs": {
"tasks": {
"terms": {"field": "task"},
"terms": {"field": "task", "size": max_tasks},
"aggs": {
"metrics": {
"terms": {"field": "metric", "size": max_metrics},
"aggs": {
"iters": {
"terms": {
"field": "iter",
"size": iters,
"order": {"_key": "desc"},
}
}
},
}
},
}
},
"query": {"bool": {"must": must}},
}
with translate_errors_context():
es_res = search_company_events(
self.es,
company_id=company_ids,
event_type=event_type,
body=es_req,
)
if "aggregations" not in es_res:
return {}
return {
(tb["key"], mb["key"]): [ib["key"] for ib in mb["iters"]["buckets"]]
for tb in es_res["aggregations"]["tasks"]["buckets"]
for mb in tb["metrics"]["buckets"]
}
def get_last_iters(
self,
company_id: Union[str, Sequence[str]],
event_type: EventType,
task_id: Union[str, Sequence[str]],
iters: int,
metrics: MetricVariants = None,
) -> Mapping[str, Sequence]:
company_ids = [company_id] if isinstance(company_id, str) else company_id
company_ids = [
c_id
for c_id in set(company_ids)
if not check_empty_data(self.es, c_id, event_type)
]
if not company_ids:
return {}
task_ids = [task_id] if isinstance(task_id, str) else task_id
must = [{"terms": {"task": task_ids}}]
if metrics:
must.append(get_metric_variants_condition(metrics))
max_tasks = min(len(task_ids), 1000)
es_req: dict = {
"size": 0,
"aggs": {
"tasks": {
"terms": {"field": "task", "size": max_tasks},
"aggs": {
"iters": {
"terms": {
@@ -1040,12 +1108,15 @@ class EventBLL(object):
},
}
},
"query": {"bool": {"must": [{"terms": {"task": task_ids}}]}},
"query": {"bool": {"must": must}},
}
with translate_errors_context():
es_res = search_company_events(
self.es, company_id=company_id, event_type=event_type, body=es_req,
self.es,
company_id=company_ids,
event_type=event_type,
body=es_req,
)
if "aggregations" not in es_res:
@@ -1096,18 +1167,26 @@ class EventBLL(object):
return {"refresh": True}
def delete_task_events(
self, company_id, task_id, allow_locked=False, model=False, async_delete=False,
):
def delete_task_events(self, company_id, task_id, allow_locked=False, model=False):
if model:
self._validate_model_state(
company_id=company_id, model_id=task_id, allow_locked=allow_locked,
company_id=company_id,
model_id=task_id,
allow_locked=allow_locked,
)
else:
self._validate_task_state(
company_id=company_id, task_id=task_id, allow_locked=allow_locked
)
async_delete = async_task_events_delete
if async_delete:
total = self.events_iterator.count_task_events(
event_type=EventType.all,
company_id=company_id,
task_ids=[task_id],
)
if total <= async_delete_threshold:
async_delete = False
es_req = {"query": {"term": {"task": task_id}}}
with translate_errors_context():
es_res = delete_company_events(
@@ -1165,24 +1244,37 @@ class EventBLL(object):
return es_res.get("deleted", 0)
def delete_multi_task_events(
self, company_id: str, task_ids: Sequence[str], async_delete=False
self, company_id: str, task_ids: Sequence[str], model=False
):
"""
Delete mutliple task events. No check is done for tasks write access
Delete multiple task events. No check is done for tasks write access
so it should be checked by the calling code
"""
es_req = {"query": {"terms": {"task": task_ids}}}
deleted = 0
with translate_errors_context():
es_res = delete_company_events(
es=self.es,
company_id=company_id,
event_type=EventType.all,
body=es_req,
**self._get_events_deletion_params(async_delete),
)
async_delete = async_task_events_delete
if async_delete and len(task_ids) < 100:
total = self.events_iterator.count_task_events(
event_type=EventType.all,
company_id=company_id,
task_ids=task_ids,
)
if total <= async_delete_threshold:
async_delete = False
for tasks in chunked_iter(task_ids, 100):
es_req = {"query": {"terms": {"task": tasks}}}
es_res = delete_company_events(
es=self.es,
company_id=company_id,
event_type=EventType.all,
body=es_req,
**self._get_events_deletion_params(async_delete),
)
if not async_delete:
deleted += es_res.get("deleted", 0)
if not async_delete:
return es_res.get("deleted", 0)
return deleted
def clear_scroll(self, scroll_id: str):
if scroll_id == self.empty_scroll:

View File

@@ -8,6 +8,7 @@ from elasticsearch import Elasticsearch
from apiserver.config_repo import config
from apiserver.database.errors import translate_errors_context
from apiserver.database.model.task.task import Task
from apiserver.tools import safe_get
@@ -22,6 +23,7 @@ class EventType(Enum):
SINGLE_SCALAR_ITERATION = -(2 ** 31)
MetricVariants = Mapping[str, Sequence[str]]
TaskCompanies = Mapping[str, Sequence[Task]]
class EventSettings:
@@ -52,14 +54,17 @@ class EventSettings:
return int(self._max_es_allowed_aggregation_buckets * percentage)
def get_index_name(company_id: str, event_type: str):
def get_index_name(company_id: Union[str, Sequence[str]], event_type: str):
event_type = event_type.lower().replace(" ", "_")
return f"events-{event_type}-{company_id.lower()}"
if isinstance(company_id, str):
company_id = [company_id]
return ",".join(f"events-{event_type}-{(c_id or '').lower()}" for c_id in company_id)
def check_empty_data(es: Elasticsearch, company_id: str, event_type: EventType) -> bool:
es_index = get_index_name(company_id, event_type.value)
if not es.indices.exists(es_index):
if not es.indices.exists(index=es_index):
return True
return False

View File

@@ -18,11 +18,11 @@ from apiserver.bll.event.event_common import (
get_metric_variants_condition,
get_max_metric_and_variant_counts,
SINGLE_SCALAR_ITERATION,
TaskCompanies,
)
from apiserver.bll.event.scalar_key import ScalarKey, ScalarKeyEnum
from apiserver.config_repo import config
from apiserver.database.errors import translate_errors_context
from apiserver.database.model.task.task import Task
from apiserver.tools import safe_get
log = config.logger(__file__)
@@ -108,37 +108,51 @@ class EventMetrics:
def compare_scalar_metrics_average_per_iter(
self,
company_id,
tasks: Sequence[Task],
companies: TaskCompanies,
samples,
key: ScalarKeyEnum,
metric_variants: MetricVariants = None,
):
"""
Compare scalar metrics for different tasks per metric and variant
The amount of points in each histogram should not exceed the requested samples
"""
event_type = EventType.metrics_scalar
if check_empty_data(self.es, company_id=company_id, event_type=event_type):
companies = {
company_id: tasks
for company_id, tasks in companies.items()
if not check_empty_data(
self.es, company_id=company_id, event_type=event_type
)
}
if not companies:
return {}
task_name_by_id = {t.id: t.name for t in tasks}
get_scalar_average_per_iter = partial(
self._get_scalar_average_per_iter_core,
company_id=company_id,
event_type=event_type,
samples=samples,
key=ScalarKey.resolve(key),
metric_variants=metric_variants,
run_parallel=False,
)
task_ids = [t.id for t in tasks]
task_ids, company_ids = zip(
*(
(t.id, t.company)
for t in itertools.chain.from_iterable(companies.values())
)
)
with ThreadPoolExecutor(max_workers=EventSettings.max_workers) as pool:
task_metrics = zip(
task_ids, pool.map(get_scalar_average_per_iter, task_ids)
task_ids, pool.map(get_scalar_average_per_iter, task_ids, company_ids)
)
task_names = {
t.id: t.name for t in itertools.chain.from_iterable(companies.values())
}
res = defaultdict(lambda: defaultdict(dict))
for task_id, task_data in task_metrics:
task_name = task_name_by_id[task_id]
task_name = task_names[task_id]
for metric_key, metric_data in task_data.items():
for variant_key, variant_data in metric_data.items():
variant_data["name"] = task_name
@@ -147,18 +161,27 @@ class EventMetrics:
return res
def get_task_single_value_metrics(
self, company_id: str, tasks: Sequence[Task]
self, companies: TaskCompanies
) -> Mapping[str, dict]:
"""
For the requested tasks return all the events delivered for the single iteration (-2**31)
"""
if check_empty_data(
self.es, company_id=company_id, event_type=EventType.metrics_scalar
):
companies = {
company_id: [t.id for t in tasks]
for company_id, tasks in companies.items()
if not check_empty_data(
self.es, company_id=company_id, event_type=EventType.metrics_scalar
)
}
if not companies:
return {}
task_ids = [t.id for t in tasks]
task_events = self._get_task_single_value_metrics(company_id, task_ids)
with ThreadPoolExecutor(max_workers=EventSettings.max_workers) as pool:
task_events = list(
itertools.chain.from_iterable(
pool.map(self._get_task_single_value_metrics, companies.items())
),
)
def _get_value(event: dict):
return {
@@ -172,8 +195,9 @@ class EventMetrics:
}
def _get_task_single_value_metrics(
self, company_id: str, task_ids: Sequence[str]
self, tasks: Tuple[str, Sequence[str]]
) -> Sequence[dict]:
company_id, task_ids = tasks
es_req = {
"size": 10000,
"query": {

View File

@@ -64,13 +64,13 @@ class EventsIterator:
self,
event_type: EventType,
company_id: str,
task_id: str,
task_ids: Sequence[str],
metric_variants: MetricVariants = None,
) -> int:
if check_empty_data(self.es, company_id, event_type):
return 0
query, _ = self._get_initial_query_and_must(task_id, metric_variants)
query, _ = self._get_initial_query_and_must(task_ids, metric_variants)
es_req = {
"query": query,
}
@@ -100,7 +100,7 @@ class EventsIterator:
For the last key-field value all the events are brought (even if the resulting size exceeds batch_size)
so that events with this value will not be lost between the calls.
"""
query, must = self._get_initial_query_and_must(task_id, metric_variants)
query, must = self._get_initial_query_and_must([task_id], metric_variants)
# retrieve the next batch of events
es_req = {
@@ -158,14 +158,14 @@ class EventsIterator:
@staticmethod
def _get_initial_query_and_must(
task_id: str, metric_variants: MetricVariants = None
task_ids: Sequence[str], metric_variants: MetricVariants = None
) -> Tuple[dict, list]:
if not metric_variants:
must = [{"term": {"task": task_id}}]
query = {"term": {"task": task_id}}
query = {"terms": {"task": task_ids}}
must = [query]
else:
must = [
{"term": {"task": task_id}},
{"terms": {"task": task_ids}},
get_metric_variants_condition(metric_variants),
]
query = {"bool": {"must": must}}

View File

@@ -75,18 +75,25 @@ class MetricEventsIterator:
def get_task_events(
self,
company_id: str,
companies: Mapping[str, str],
task_metrics: Mapping[str, dict],
iter_count: int,
navigate_earlier: bool = True,
refresh: bool = False,
state_id: str = None,
) -> MetricEventsResult:
if check_empty_data(self.es, company_id, self.event_type):
companies = {
task_id: company_id
for task_id, company_id in companies.items()
if not check_empty_data(
self.es, company_id=company_id, event_type=self.event_type
)
}
if not companies:
return MetricEventsResult()
def init_state(state_: MetricEventsScrollState):
state_.tasks = self._init_task_states(company_id, task_metrics)
state_.tasks = self._init_task_states(companies, task_metrics)
def validate_state(state_: MetricEventsScrollState):
"""
@@ -95,7 +102,7 @@ class MetricEventsIterator:
Refresh the state if requested
"""
if refresh:
self._reinit_outdated_task_states(company_id, state_, task_metrics)
self._reinit_outdated_task_states(companies, state_, task_metrics)
with self.cache_manager.get_or_create_state(
state_id=state_id, init_state=init_state, validate_state=validate_state
@@ -112,7 +119,7 @@ class MetricEventsIterator:
pool.map(
partial(
self._get_task_metric_events,
company_id=company_id,
companies=companies,
iter_count=iter_count,
navigate_earlier=navigate_earlier,
specific_variants_requested=specific_variants_requested,
@@ -125,7 +132,7 @@ class MetricEventsIterator:
def _reinit_outdated_task_states(
self,
company_id,
companies: Mapping[str, str],
state: MetricEventsScrollState,
task_metrics: Mapping[str, dict],
):
@@ -133,9 +140,7 @@ class MetricEventsIterator:
Determine the metrics for which new event_type events were added
since their states were initialized and re-init these states
"""
tasks = Task.objects(id__in=list(task_metrics), company=company_id).only(
"id", "metric_stats"
)
tasks = Task.objects(id__in=list(task_metrics)).only("id", "metric_stats")
def get_last_update_times_for_task_metrics(
task: Task,
@@ -175,7 +180,7 @@ class MetricEventsIterator:
if metrics_to_recalc:
task_metrics_to_recalc[task] = metrics_to_recalc
updated_task_states = self._init_task_states(company_id, task_metrics_to_recalc)
updated_task_states = self._init_task_states(companies, task_metrics_to_recalc)
def merge_with_updated_task_states(
old_state: TaskScrollState, updates: Sequence[TaskScrollState]
@@ -205,14 +210,14 @@ class MetricEventsIterator:
]
def _init_task_states(
self, company_id: str, task_metrics: Mapping[str, dict]
self, companies: Mapping[str, str], task_metrics: Mapping[str, dict]
) -> Sequence[TaskScrollState]:
"""
Returned initialized metric scroll stated for the requested task metrics
"""
with ThreadPoolExecutor(EventSettings.max_workers) as pool:
task_metric_states = pool.map(
partial(self._init_metric_states_for_task, company_id=company_id),
partial(self._init_metric_states_for_task, companies=companies),
task_metrics.items(),
)
@@ -232,13 +237,14 @@ class MetricEventsIterator:
pass
def _init_metric_states_for_task(
self, task_metrics: Tuple[str, dict], company_id: str
self, task_metrics: Tuple[str, dict], companies: Mapping[str, str]
) -> Sequence[MetricState]:
"""
Return metric scroll states for the task filled with the variant states
for the variants that reported any event_type events
"""
task, metrics = task_metrics
company_id = companies[task]
must = [{"term": {"task": task}}, *self._get_extra_conditions()]
if metrics:
must.append(get_metric_variants_condition(metrics))
@@ -319,7 +325,7 @@ class MetricEventsIterator:
def _get_task_metric_events(
self,
task_state: TaskScrollState,
company_id: str,
companies: Mapping[str, str],
iter_count: int,
navigate_earlier: bool,
specific_variants_requested: bool,
@@ -391,7 +397,10 @@ class MetricEventsIterator:
}
with translate_errors_context():
es_res = search_company_events(
self.es, company_id=company_id, event_type=self.event_type, body=es_req,
self.es,
company_id=companies[task_state.task],
event_type=self.event_type,
body=es_req,
)
if "aggregations" not in es_res:
return task_state.task, []

View File

@@ -5,7 +5,8 @@ from mongoengine import Q
from apiserver.apierrors import errors
from apiserver.apimodels.models import ModelTaskPublishResponse
from apiserver.bll.task.utils import deleted_prefix
from apiserver.bll.task.utils import deleted_prefix, get_last_metric_updates
from apiserver.config_repo import config
from apiserver.database.model import EntityVisibility
from apiserver.database.model.model import Model
from apiserver.database.model.task.task import Task, TaskStatus
@@ -13,6 +14,8 @@ from .metadata import Metadata
class ModelBLL:
event_bll = None
@classmethod
def get_company_model_by_id(
cls, company_id: str, model_id: str, only_fields=None
@@ -28,11 +31,7 @@ class ModelBLL:
@staticmethod
def assert_exists(
company_id,
model_ids,
only=None,
allow_public=False,
return_models=True,
company_id, model_ids, only=None, allow_public=False, return_models=True,
) -> Optional[Sequence[Model]]:
model_ids = [model_ids] if isinstance(model_ids, str) else model_ids
ids = set(model_ids)
@@ -58,8 +57,9 @@ class ModelBLL:
cls,
model_id: str,
company_id: str,
user_id: str,
force_publish_task: bool = False,
publish_task_func: Callable[[str, str, bool], dict] = None,
publish_task_func: Callable[[str, str, str, bool], dict] = None,
) -> Tuple[int, ModelTaskPublishResponse]:
model = cls.get_company_model_by_id(company_id=company_id, model_id=model_id)
if model.ready:
@@ -74,18 +74,25 @@ class ModelBLL:
)
if task and task.status != TaskStatus.published:
task_publish_res = publish_task_func(
model.task, company_id, force_publish_task
model.task, company_id, user_id, force_publish_task
)
published_task = ModelTaskPublishResponse(
id=model.task, data=task_publish_res
)
updated = model.update(upsert=False, ready=True, last_update=datetime.utcnow())
now = datetime.utcnow()
updated = model.update(
upsert=False,
ready=True,
last_update=now,
last_change=now,
last_changed_by=user_id,
)
return updated, published_task
@classmethod
def delete_model(
cls, model_id: str, company_id: str, force: bool
cls, model_id: str, company_id: str, user_id: str, force: bool, delete_external_artifacts: bool = True,
) -> Tuple[int, Model]:
model = cls.get_company_model_by_id(
company_id=company_id,
@@ -111,49 +118,88 @@ class ModelBLL:
if model.task:
task = Task.objects(id=model.task).first()
if task and task.status == TaskStatus.published:
if not force:
raise errors.bad_request.ModelCreatingTaskExists(
"and published, use force=True to delete", task=model.task
)
if task.models.output and model_id in task.models.output:
now = datetime.utcnow()
if task:
now = datetime.utcnow()
if task.status == TaskStatus.published:
if not force:
raise errors.bad_request.ModelCreatingTaskExists(
"and published, use force=True to delete", task=model.task
)
Task._get_collection().update_one(
filter={"_id": model.task, "models.output.model": model_id},
update={
"$set": {
"models.output.$[elem].model": deleted_model_id,
"output.error": f"model deleted on {now.isoformat()}",
"last_change": now,
"last_changed_by": user_id,
},
"last_change": now,
},
array_filters=[{"elem.model": model_id}],
upsert=False,
)
else:
task.update(
pull__models__output__model=model_id,
set__last_change=now,
set__last_changed_by=user_id,
)
delete_external_artifacts = delete_external_artifacts and config.get(
"services.async_urls_delete.enabled", True
)
if delete_external_artifacts:
from apiserver.bll.task.task_cleanup import (
collect_debug_image_urls,
collect_plot_image_urls,
_schedule_for_delete,
)
urls = set()
urls.update(collect_debug_image_urls(company_id, model_id))
urls.update(collect_plot_image_urls(company_id, model_id))
if model.uri:
urls.add(model.uri)
if urls:
_schedule_for_delete(
task_id=model_id,
company=company_id,
user=user_id,
urls=urls,
can_delete_folders=False,
)
if not cls.event_bll:
from apiserver.bll.event import EventBLL
cls.event_bll = EventBLL()
cls.event_bll.delete_task_events(company_id, model_id, allow_locked=True, model=True)
del_count = Model.objects(id=model_id, company=company_id).delete()
return del_count, model
@classmethod
def archive_model(cls, model_id: str, company_id: str):
def archive_model(cls, model_id: str, company_id: str, user_id: str):
cls.get_company_model_by_id(
company_id=company_id, model_id=model_id, only_fields=("id",)
)
now = datetime.utcnow()
archived = Model.objects(company=company_id, id=model_id).update(
add_to_set__system_tags=EntityVisibility.archived.value,
last_update=datetime.utcnow(),
last_change=now,
last_changed_by=user_id,
)
return archived
@classmethod
def unarchive_model(cls, model_id: str, company_id: str):
def unarchive_model(cls, model_id: str, company_id: str, user_id: str):
cls.get_company_model_by_id(
company_id=company_id, model_id=model_id, only_fields=("id",)
)
now = datetime.utcnow()
unarchived = Model.objects(company=company_id, id=model_id).update(
pull__system_tags=EntityVisibility.archived.value,
last_update=datetime.utcnow(),
last_change=now,
last_changed_by=user_id,
)
return unarchived
@@ -178,12 +224,43 @@ class ModelBLL:
"labels_count": {"$size": {"$objectToArray": "$labels"}}
}
},
{
"$project": {"labels_count": 1},
},
{"$project": {"labels_count": 1}},
]
)
return {
r.pop("_id"): r
for r in result
return {r.pop("_id"): r for r in result}
@staticmethod
def update_statistics(
company_id: str,
user_id: str,
model_id: str,
last_update: datetime = None,
last_iteration_max: int = None,
last_scalar_events: Dict[str, Dict[str, dict]] = None,
):
last_update = last_update or datetime.utcnow()
updates = {
"last_update": datetime.utcnow(),
"last_change": last_update,
"last_changed_by": user_id,
}
if last_iteration_max is not None:
updates.update(max__last_iteration=last_iteration_max)
raw_updates = {}
if last_scalar_events is not None:
raw_updates = {}
if last_scalar_events is not None:
get_last_metric_updates(
task_id=model_id,
last_scalar_events=last_scalar_events,
raw_updates=raw_updates,
extra_updates=updates,
model_events=True,
)
ret = Model.objects(id=model_id).update_one(**updates)
if ret and raw_updates:
Model.objects(id=model_id).update_one(__raw__=[{"$set": raw_updates}])
return ret

View File

@@ -5,7 +5,6 @@ from mongoengine import Document
from apiserver.apierrors import errors
from apiserver.apimodels.metadata import MetadataItem
from apiserver.database.model.base import GetMixin
from apiserver.service_repo import APICall
from apiserver.utilities.parameter_key_escaper import (
ParameterKeyEscaper,
mongoengine_safe,
@@ -87,13 +86,13 @@ class Metadata:
return paths
@classmethod
def escape_query_parameters(cls, call: APICall) -> dict:
if not call.data:
return call.data
def escape_query_parameters(cls, call_data: dict) -> dict:
if not call_data:
return call_data
keys = list(call.data)
keys = list(call_data)
call_data = {
safe_key: call.data[key]
safe_key: call_data[key]
for key, safe_key in zip(keys, Metadata.escape_paths(keys))
}

View File

@@ -1,8 +1,11 @@
from collections import defaultdict
from enum import Enum
from typing import Sequence, Dict
from typing import Sequence, Dict, Type
from apiserver.apierrors import errors
from apiserver.bll.util import update_project_time
from apiserver.config_repo import config
from apiserver.database.model.model import AttributedDocument
from apiserver.database.model.model import Model
from apiserver.database.model.task.task import Task
from apiserver.redis_manager import redman
@@ -22,6 +25,51 @@ class OrgBLL:
self._task_tags = _TagsCache(Task, self.redis)
self._model_tags = _TagsCache(Model, self.redis)
def edit_entity_tags(
self,
company_id,
entity_cls: Type[AttributedDocument],
entity_ids: Sequence[str],
add_tags: Sequence[str],
remove_tags: Sequence[str],
) -> int:
if entity_cls not in (Task, Model):
raise errors.bad_request.ValidationError(
"Tags editing can be called on tasks or models only"
)
if not entity_ids:
raise errors.bad_request.ValidationError(
"No entity ids provided for editing tags"
)
if not (add_tags or remove_tags):
raise errors.bad_request.ValidationError(
"Either add tags or remove tags should be provided"
)
updated = 0
if add_tags:
updated += entity_cls.objects(company=company_id, id__in=entity_ids).update(
add_to_set__tags=add_tags
)
if remove_tags:
updated += entity_cls.objects(company=company_id, id__in=entity_ids).update(
pull_all__tags=remove_tags
)
if not updated:
return 0
projects = entity_cls.objects(company=company_id, id__in=entity_ids).distinct(
"project"
)
update_project_time(project_ids=projects)
self.update_tags(
company_id,
entity=Tags.Task if entity_cls is Task else Tags.Model,
projects=projects,
tags=add_tags or remove_tags
)
return updated
def get_tags(
self,
company_id: str,
@@ -50,10 +98,10 @@ class OrgBLL:
return ret
def update_tags(
self, company_id: str, entity: Tags, project: str, tags=None, system_tags=None,
self, company_id: str, entity: Tags, projects: Sequence[str], tags=None, system_tags=None,
):
tags_cache = self._get_tags_cache_for_entity(entity)
tags_cache.update_tags(company_id, project, tags, system_tags)
tags_cache.update_tags(company_id, projects, tags, system_tags)
def reset_tags(self, company_id: str, entity: Tags, projects: Sequence[str]):
tags_cache = self._get_tags_cache_for_entity(entity)

View File

@@ -107,7 +107,7 @@ class _TagsCache:
return ret
def update_tags(self, company_id: str, project: str, tags=None, system_tags=None):
def update_tags(self, company_id: str, projects: Sequence[str], tags=None, system_tags=None):
"""
Updates tags. If reset is set then both tags and system_tags
are recalculated. Otherwise only those that are not 'None'
@@ -123,7 +123,7 @@ class _TagsCache:
if not fields:
return
self._delete_redis_keys(company_id, projects=[project], fields=fields)
self._delete_redis_keys(company_id, projects=projects, fields=fields)
def reset_tags(self, company_id: str, projects: Sequence[str]):
self._delete_redis_keys(

View File

@@ -1,8 +1,7 @@
import itertools
from collections import defaultdict
from datetime import datetime, timedelta
from functools import reduce
from itertools import groupby
from itertools import groupby, chain
from operator import itemgetter
from typing import (
Sequence,
@@ -15,15 +14,16 @@ from typing import (
Callable,
Mapping,
Any,
Union,
)
from boltons.iterutils import partition
from mongoengine import Q, Document
from apiserver import database
from apiserver.apierrors import errors
from apiserver.apimodels.projects import ProjectChildrenType
from apiserver.config_repo import config
from apiserver.database.model import EntityVisibility, AttributedDocument
from apiserver.database.model import EntityVisibility, AttributedDocument, User
from apiserver.database.model.base import GetMixin
from apiserver.database.model.model import Model
from apiserver.database.model.project import Project
@@ -40,16 +40,25 @@ from .sub_projects import (
_ids_with_children,
_ids_with_parents,
_get_project_depth,
ProjectsChildren,
)
log = config.logger(__file__)
max_depth = config.get("services.projects.sub_projects.max_depth", 10)
reports_project_name = ".reports"
datasets_project_name = ".datasets"
pipelines_project_name = ".pipelines"
reports_tag = "reports"
dataset_tag = "dataset"
pipeline_tag = "pipeline"
class ProjectBLL:
child_classes = (Task, Model)
@classmethod
def merge_project(
cls, company, source_id: str, destination_id: str
cls, company: str, source_id: str, destination_id: str
) -> Tuple[int, int, Set[str]]:
"""
Move all the tasks and sub projects from the source project to the destination
@@ -81,7 +90,7 @@ class ProjectBLL:
)
moved_entities = 0
for entity_type in (Task, Model):
for entity_type in cls.child_classes:
moved_entities += entity_type.objects(
company=company,
project=source_id,
@@ -297,18 +306,21 @@ class ProjectBLL:
"""
Move a batch of entities to `project` or a project named `project_name` (create if does not exist)
"""
project = cls.find_or_create(
user=user,
company=company,
project_id=project,
project_name=project_name,
description="",
)
extra = (
{"set__last_change": datetime.utcnow()}
if hasattr(entity_cls, "last_change")
else {}
)
if project_name or project:
project = cls.find_or_create(
user=user,
company=company,
project_id=project,
project_name=project_name,
description="",
)
extra = {}
if hasattr(entity_cls, "last_change"):
extra["set__last_change"] = datetime.utcnow()
if hasattr(entity_cls, "last_changed_by"):
extra["set__last_changed_by"] = user
entity_cls.objects(company=company, id__in=ids).update(
set__project=project, **extra
)
@@ -394,6 +406,18 @@ class ProjectBLL:
"$completed",
{"$gt": ["$completed", time_thresh]},
additional_cond,
{
"$not": {
"$in": [
"$status",
[
TaskStatus.queued,
TaskStatus.in_progress,
TaskStatus.failed,
],
]
}
},
]
},
"then": 1,
@@ -507,7 +531,7 @@ class ProjectBLL:
def aggregate_project_data(
func: Callable[[T, T], T],
project_ids: Sequence[str],
child_projects: Mapping[str, Sequence[Project]],
child_projects: ProjectsChildren,
data: Mapping[str, T],
) -> Dict[str, T]:
"""
@@ -527,7 +551,10 @@ class ProjectBLL:
@classmethod
def get_dataset_stats(
cls, company: str, project_ids: Sequence[str], users: Sequence[str] = None,
cls,
company: str,
project_ids: Sequence[str],
users: Sequence[str] = None,
) -> Dict[str, dict]:
if not project_ids:
return {}
@@ -559,6 +586,140 @@ class ProjectBLL:
for r in Task.aggregate(task_runtime_pipeline)
}
@staticmethod
def _get_projects_children(
project_ids: Sequence[str],
search_hidden: bool,
allowed_ids: Sequence[str],
) -> Tuple[ProjectsChildren, Set[str]]:
child_projects = _get_sub_projects(
project_ids,
_only=("id", "name"),
search_hidden=search_hidden,
allowed_ids=allowed_ids,
)
return (
child_projects,
{c.id for c in chain.from_iterable(child_projects.values())},
)
@staticmethod
def _get_children_info(
project_ids: Sequence[str], child_projects: ProjectsChildren
) -> dict:
return {
project: sorted(
[{"id": c.id, "name": c.name} for c in child_projects.get(project, [])],
key=itemgetter("name"),
)
for project in project_ids
}
@classmethod
def _get_project_dataset_stats_core(
cls,
company: str,
project_ids: Sequence[str],
project_field: str,
entity_class: Type[AttributedDocument],
include_children: bool = True,
filter_: Mapping[str, Any] = None,
users: Sequence[str] = None,
selected_project_ids: Sequence[str] = None,
) -> Tuple[Dict[str, dict], Dict[str, dict]]:
if not project_ids:
return {}, {}
child_projects = {}
project_ids_with_children = set(project_ids)
if include_children:
child_projects, children_ids = cls._get_projects_children(
project_ids,
search_hidden=True,
allowed_ids=selected_project_ids,
)
project_ids_with_children |= children_ids
pipeline = [
{
"$match": cls.get_match_conditions(
company=company,
project_ids=list(project_ids_with_children),
filter_=filter_,
users=users,
project_field=project_field,
)
},
{"$project": {project_field: 1, "tags": 1}},
{
"$group": {
"_id": f"${project_field}",
"count": {"$sum": 1},
"tags": {"$push": "$tags"},
}
},
]
res = entity_class.aggregate(pipeline)
project_stats = {
result["_id"]: {
"count": result.get("count", 0),
"tags": set(chain.from_iterable(result.get("tags", []))),
}
for result in res
}
def concat_dataset_stats(a: dict, b: dict) -> dict:
return {
"count": a.get("count", 0) + b.get("count", 0),
"tags": a.get("tags", {}) | b.get("tags", {}),
}
top_project_stats = cls.aggregate_project_data(
func=concat_dataset_stats,
project_ids=project_ids,
child_projects=child_projects,
data=project_stats,
)
for _, stat in top_project_stats.items():
stat["tags"] = sorted(list(stat.get("tags", {})))
empty_stats = {"count": 0, "tags": []}
stats = {
project: {"datasets": top_project_stats.get(project, empty_stats)}
for project in project_ids
}
return stats, cls._get_children_info(project_ids, child_projects)
@classmethod
def get_project_dataset_stats(
cls,
company: str,
project_ids: Sequence[str],
include_children: bool = True,
filter_: Mapping[str, Any] = None,
users: Sequence[str] = None,
selected_project_ids: Sequence[str] = None,
) -> Tuple[Dict[str, dict], Dict[str, dict]]:
filter_ = filter_ or {}
filter_system_tags = filter_.get("system_tags")
if not isinstance(filter_system_tags, list):
filter_system_tags = []
if dataset_tag not in filter_system_tags:
filter_system_tags.append(dataset_tag)
filter_["system_tags"] = filter_system_tags
return cls._get_project_dataset_stats_core(
company=company,
project_ids=project_ids,
project_field="parent",
entity_class=Project,
include_children=include_children,
filter_=filter_,
users=users,
selected_project_ids=selected_project_ids,
)
@classmethod
def get_project_stats(
cls,
@@ -569,24 +730,21 @@ class ProjectBLL:
search_hidden: bool = False,
filter_: Mapping[str, Any] = None,
users: Sequence[str] = None,
user_active_project_ids: Sequence[str] = None,
selected_project_ids: Sequence[str] = None,
) -> Tuple[Dict[str, dict], Dict[str, dict]]:
if not project_ids:
return {}, {}
child_projects = (
_get_sub_projects(
child_projects = {}
project_ids_with_children = set(project_ids)
if include_children:
child_projects, children_ids = cls._get_projects_children(
project_ids,
_only=("id", "name"),
search_hidden=search_hidden,
allowed_ids=user_active_project_ids,
allowed_ids=selected_project_ids,
)
if include_children
else {}
)
project_ids_with_children = set(project_ids) | {
c.id for c in itertools.chain.from_iterable(child_projects.values())
}
project_ids_with_children |= children_ids
status_count_pipeline, runtime_pipeline = cls.make_projects_get_all_pipelines(
company,
project_ids=list(project_ids_with_children),
@@ -690,14 +848,7 @@ class ProjectBLL:
for project in project_ids
}
children = {
project: sorted(
[{"id": c.id, "name": c.name} for c in child_projects.get(project, [])],
key=itemgetter("name"),
)
for project in project_ids
}
return stats, children
return stats, cls._get_children_info(project_ids, child_projects)
@classmethod
def get_active_users(
@@ -722,7 +873,7 @@ class ProjectBLL:
projects_query &= Q(id__in=project_ids)
res = set(Project.objects(projects_query).distinct(field="user"))
for cls_ in (Task, Model):
for cls_ in cls.child_classes:
res |= set(cls_.objects(query).distinct(field="user"))
return res
@@ -751,61 +902,102 @@ class ProjectBLL:
return tags, system_tags
@classmethod
def get_projects_with_active_user(
def get_projects_with_selected_children(
cls,
company: str,
users: Sequence[str],
users: Sequence[str] = None,
project_ids: Optional[Sequence[str]] = None,
allow_public: bool = True,
children_type: ProjectChildrenType = None,
children_tags: Sequence[str] = None,
children_tags_filter: dict = None,
) -> Tuple[Sequence[str], Sequence[str]]:
"""
Get the projects ids where user created any tasks including all the parents of these projects
Get the projects ids matching children_condition (if passed) or where the passed user created any tasks
including all the parents of these projects
If project ids are specified then filter the results by these project ids
"""
query = Q(user__in=users)
if not (users or children_type):
raise errors.bad_request.ValidationError(
"Either active users or children_condition should be specified"
)
if allow_public:
query &= get_company_or_none_constraint(company)
query = (
get_company_or_none_constraint(company)
if allow_public
else Q(company=company)
)
if users:
query &= Q(user__in=users)
project_query = None
if children_tags_filter:
child_query = query & GetMixin.get_list_filter_query(
"tags", children_tags_filter
)
elif children_tags:
child_query = query & GetMixin.get_list_field_query("tags", children_tags)
else:
query &= Q(company=company)
child_query = query
if children_type == ProjectChildrenType.dataset:
child_queries = {
Project: child_query
& Q(system_tags__in=[dataset_tag], basename__ne=datasets_project_name)
}
elif children_type == ProjectChildrenType.pipeline:
child_queries = {
Project: child_query
& Q(system_tags__in=[pipeline_tag], basename__ne=pipelines_project_name)
}
elif children_type == ProjectChildrenType.report:
child_queries = {Task: child_query & Q(system_tags__in=[reports_tag])}
else:
project_query = query
child_queries = {entity_cls: query for entity_cls in cls.child_classes}
user_projects_query = query
if project_ids:
ids_with_children = _ids_with_children(project_ids)
query &= Q(project__in=ids_with_children)
user_projects_query &= Q(id__in=ids_with_children)
if project_query:
project_query &= Q(id__in=ids_with_children)
for child_cls in child_queries:
child_queries[child_cls] &= (
Q(parent__in=ids_with_children)
if child_cls is Project
else Q(project__in=ids_with_children)
)
res = {p.id for p in Project.objects(user_projects_query).only("id")}
for cls_ in (Task, Model):
res |= set(cls_.objects(query).distinct(field="project"))
res = (
set(Project.objects(project_query).scalar("id")) if project_query else set()
)
for cls_, query_ in child_queries.items():
res |= set(
cls_.objects(query_).distinct(
field="id" if cls_ is Project else "project"
)
)
res = list(res)
if not res:
return res, res
user_active_project_ids = _ids_with_parents(res)
selected_project_ids = _ids_with_parents(res)
filtered_ids = (
list(set(user_active_project_ids) & set(project_ids))
list(set(selected_project_ids) & set(project_ids))
if project_ids
else list(user_active_project_ids)
else list(selected_project_ids)
)
return filtered_ids, user_active_project_ids
return filtered_ids, selected_project_ids
@classmethod
def get_task_parents(
cls,
company_id: str,
projects: Sequence[str],
include_subprojects: bool,
@staticmethod
def _get_project_query(
company: str,
projects: Sequence,
include_subprojects: bool = True,
state: Optional[EntityVisibility] = None,
) -> Sequence[dict]:
"""
Get list of unique parent tasks sorted by task name for the passed company projects
If projects is None or empty then get parents for all the company tasks
"""
query = Q(company=company_id)
) -> Q:
query = get_company_or_none_constraint(company)
if projects:
if include_subprojects:
projects = _ids_with_children(projects)
@@ -818,6 +1010,25 @@ class ProjectBLL:
elif state == EntityVisibility.active:
query &= Q(system_tags__nin=[EntityVisibility.archived.value])
return query
@classmethod
def get_task_parents(
cls,
company_id: str,
projects: Sequence[str],
include_subprojects: bool,
state: Optional[EntityVisibility] = None,
name: str = None,
) -> Sequence[dict]:
"""
Get list of unique parent tasks sorted by task name for the passed company projects
If projects is None or empty then get parents for all the company tasks
"""
query = cls._get_project_query(
company_id, projects, include_subprojects=include_subprojects, state=state
)
parent_ids = set(Task.objects(query).distinct("parent"))
if not parent_ids:
return []
@@ -825,23 +1036,37 @@ class ProjectBLL:
parents = Task.get_many_with_join(
company_id,
query=Q(id__in=parent_ids),
query_dict={"name": name} if name else None,
allow_public=True,
override_projection=("id", "name", "project.name"),
)
return sorted(parents, key=itemgetter("name"))
@classmethod
def get_entity_users(
cls,
company: str,
entity_cls: Type[Union[Task, Model]],
projects: Sequence[str],
include_subprojects: bool,
) -> Sequence[dict]:
query = cls._get_project_query(
company, projects, include_subprojects=include_subprojects
)
user_ids = entity_cls.objects(query).distinct(field="user")
if not user_ids:
return []
users = User.objects(id__in=user_ids).only("id", "name")
return [{"id": u.id, "name": u.name} for u in users]
@classmethod
def get_task_types(cls, company, project_ids: Optional[Sequence]) -> set:
"""
Return the list of unique task types used by company and public tasks
If project ids passed then only tasks from these projects are considered
"""
query = get_company_or_none_constraint(company)
if project_ids:
project_ids = _ids_with_children(project_ids)
query &= Q(project__in=project_ids)
else:
query &= Q(system_tags__nin=[EntityVisibility.hidden.value])
query = cls._get_project_query(company, project_ids)
res = Task.objects(query).distinct(field="type")
return set(res).intersection(external_task_types)
@@ -851,10 +1076,7 @@ class ProjectBLL:
Return the list of unique frameworks used by company and public models
If project ids passed then only models from these projects are considered
"""
query = get_company_or_none_constraint(company)
if project_ids:
project_ids = _ids_with_children(project_ids)
query &= Q(project__in=project_ids)
query = cls._get_project_query(company, project_ids)
return Model.objects(query).distinct(field="framework")
@staticmethod
@@ -863,10 +1085,11 @@ class ProjectBLL:
project_ids: Sequence[str],
filter_: Mapping[str, Any],
users: Sequence[str],
project_field: str = "project",
):
conditions = {
"company": {"$in": [None, "", company]},
"project": {"$in": project_ids},
project_field: {"$in": project_ids},
}
if users:
conditions["user"] = {"$in": users}
@@ -874,29 +1097,129 @@ class ProjectBLL:
if not filter_:
return conditions
or_conditions = []
for field, field_filter in filter_.items():
if not (
field_filter
and isinstance(field_filter, list)
and all(isinstance(t, str) for t in field_filter)
):
if not (field_filter and isinstance(field_filter, (list, dict))):
raise errors.bad_request.ValidationError(
f"List of strings expected for the field: {field}"
f"Non empty list or dictionary expected for the field: {field}"
)
exclude, include = partition(field_filter, lambda x: x.startswith("-"))
conditions[field] = {
**({"$in": include} if include else {}),
**({"$nin": [e[1:] for e in exclude]} if exclude else {}),
}
if isinstance(field_filter, list):
if not all(isinstance(t, str) for t in field_filter):
raise errors.bad_request.ValidationError(
f"Only string values are allowed in the list filter: {field}"
)
helper = GetMixin.NewListFieldBucketHelper(
field, data=field_filter, legacy=True
)
op = (
Q.OR
if helper.explicit_operator and helper.global_operator == Q.OR
else Q.AND
)
db_query = {op: helper.actions}
else:
helper = GetMixin.ListQueryFilter.from_data(field, field_filter)
db_query = helper.db_query
for op, actions in db_query.items():
field_conditions = {}
for action, values in actions.items():
value = list(set(values))
for key in reversed(action.split("__")):
value = {f"${key}": value}
field_conditions.update(value)
if op == Q.OR and len(field_conditions) > 1:
or_conditions.append(
{
"$or": [
{field: {db_modifier: cond}}
for db_modifier, cond in field_conditions.items()
]
}
)
else:
conditions[field] = field_conditions
if or_conditions:
if len(or_conditions) == 1:
conditions = next(iter(or_conditions))
else:
conditions["$and"] = [c for c in or_conditions]
return conditions
@classmethod
def _calc_own_datasets_core(
cls,
company: str,
project_ids: Sequence[str],
project_field: str,
entity_class: Type[AttributedDocument],
filter_: Mapping[str, Any] = None,
users: Sequence[str] = None,
) -> Dict[str, dict]:
"""
Returns the amount of hyper datasets per requested project
"""
if not project_ids:
return {}
pipeline = [
{
"$match": cls.get_match_conditions(
company=company,
project_ids=project_ids,
filter_=filter_,
users=users,
project_field=project_field,
)
},
{"$project": {project_field: 1}},
{"$group": {"_id": f"${project_field}", "count": {"$sum": 1}}},
]
datasets = {
data["_id"]: data["count"] for data in entity_class.aggregate(pipeline)
}
return {pid: {"own_datasets": datasets.get(pid, 0)} for pid in project_ids}
@classmethod
def calc_own_datasets(
cls,
company: str,
project_ids: Sequence[str],
filter_: Mapping[str, Any] = None,
users: Sequence[str] = None,
) -> Dict[str, dict]:
"""
Returns the amount of datasets per requested project
"""
filter_ = filter_ or {}
filter_system_tags = filter_.get("system_tags")
if not isinstance(filter_system_tags, list):
filter_system_tags = []
if dataset_tag not in filter_system_tags:
filter_system_tags.append(dataset_tag)
filter_["system_tags"] = filter_system_tags
return cls._calc_own_datasets_core(
company=company,
project_ids=project_ids,
project_field="parent",
entity_class=Project,
filter_=filter_,
users=users,
)
@classmethod
def calc_own_contents(
cls,
company: str,
project_ids: Sequence[str],
filter_: Mapping[str, Any] = None,
specific_state: Optional[EntityVisibility] = None,
users: Sequence[str] = None,
) -> Dict[str, dict]:
"""
@@ -907,6 +1230,20 @@ class ProjectBLL:
if not project_ids:
return {}
if specific_state:
filter_ = filter_ or {}
system_tags_filter = filter_.get("system_tags", [])
archived = EntityVisibility.archived.value
non_archived = f"-{EntityVisibility.archived.value}"
if not any(t in system_tags_filter for t in (archived, non_archived)):
filter_ = {k: v for k, v in filter_.items()}
filter_["system_tags"] = [
archived
if specific_state == EntityVisibility.archived
else non_archived,
*system_tags_filter,
]
pipeline = [
{
"$match": cls.get_match_conditions(

View File

@@ -1,6 +1,9 @@
from collections import defaultdict
from datetime import datetime
from typing import Tuple, Set, Sequence
import attr
from mongoengine import Q
from apiserver.apierrors import errors
from apiserver.bll.event import EventBLL
@@ -14,12 +17,19 @@ from apiserver.config_repo import config
from apiserver.database.model import EntityVisibility
from apiserver.database.model.model import Model
from apiserver.database.model.project import Project
from apiserver.database.model.task.task import Task, ArtifactModes, TaskType
from apiserver.database.model.task.task import Task, ArtifactModes, TaskType, TaskStatus
from .project_bll import (
ProjectBLL,
pipeline_tag,
pipelines_project_name,
dataset_tag,
datasets_project_name,
reports_tag,
)
from .sub_projects import _ids_with_children
log = config.logger(__file__)
event_bll = EventBLL()
async_events_delete = config.get("services.tasks.async_events_delete", False)
@attr.s(auto_attribs=True)
@@ -31,30 +41,83 @@ class DeleteProjectResult:
urls: TaskUrls = None
def _get_child_project_ids(
project_id: str,
) -> Tuple[Sequence[str], Sequence[str], Sequence[str]]:
project_ids = _ids_with_children([project_id])
pipeline_ids = list(
Project.objects(
id__in=project_ids,
system_tags__in=[pipeline_tag],
basename__ne=pipelines_project_name,
).scalar("id")
)
dataset_ids = list(
Project.objects(
id__in=project_ids,
system_tags__in=[dataset_tag],
basename__ne=datasets_project_name,
).scalar("id")
)
return project_ids, pipeline_ids, dataset_ids
def validate_project_delete(company: str, project_id: str):
project = Project.get_for_writing(
company=company, id=project_id, _only=("id", "path", "system_tags")
)
if not project:
raise errors.bad_request.InvalidProjectId(id=project_id)
is_pipeline = "pipeline" in (project.system_tags or [])
project_ids = _ids_with_children([project_id])
project_ids, pipeline_ids, dataset_ids = _get_child_project_ids(project_id)
ret = {}
for cls in (Task, Model):
ret[f"{cls.__name__.lower()}s"] = cls.objects(project__in=project_ids).count()
for cls in (Task, Model):
query = dict(
project__in=project_ids, system_tags__nin=[EntityVisibility.archived.value]
)
name = f"non_archived_{cls.__name__.lower()}s"
if not is_pipeline:
ret[name] = cls.objects(**query).count()
else:
ret[name] = (
cls.objects(**query, type=TaskType.controller).count()
if cls == Task
else 0
if pipeline_ids:
pipelines_with_active_controllers = Task.objects(
project__in=pipeline_ids,
type=TaskType.controller,
system_tags__nin=[EntityVisibility.archived.value],
).distinct("project")
ret["pipelines"] = len(pipelines_with_active_controllers)
else:
ret["pipelines"] = 0
if dataset_ids:
datasets_with_data = Task.objects(
project__in=dataset_ids,
system_tags__nin=[EntityVisibility.archived.value],
).distinct("project")
ret["datasets"] = len(datasets_with_data)
else:
ret["datasets"] = 0
project_ids = list(set(project_ids) - set(pipeline_ids) - set(dataset_ids))
if project_ids:
in_project_query = Q(project__in=project_ids)
for cls in (Task, Model):
query = (
in_project_query & Q(system_tags__nin=[reports_tag])
if cls is Task
else in_project_query
)
ret[f"{cls.__name__.lower()}s"] = cls.objects(query).count()
ret[f"non_archived_{cls.__name__.lower()}s"] = cls.objects(
query & Q(system_tags__nin=[EntityVisibility.archived.value])
).count()
ret["reports"] = Task.objects(
in_project_query & Q(system_tags__in=[reports_tag])
).count()
ret["non_archived_reports"] = Task.objects(
in_project_query
& Q(
system_tags__in=[reports_tag],
system_tags__nin=[EntityVisibility.archived.value],
)
).count()
else:
for cls in (Task, Model):
ret[f"{cls.__name__.lower()}s"] = 0
ret[f"non_archived_{cls.__name__.lower()}s"] = 0
ret["reports"] = 0
ret["non_archived_reports"] = 0
return ret
@@ -65,7 +128,7 @@ def delete_project(
project_id: str,
force: bool,
delete_contents: bool,
delete_external_artifacts=True,
delete_external_artifacts: bool,
) -> Tuple[DeleteProjectResult, Set[str]]:
project = Project.get_for_writing(
company=company, id=project_id, _only=("id", "path", "system_tags")
@@ -74,39 +137,58 @@ def delete_project(
raise errors.bad_request.InvalidProjectId(id=project_id)
delete_external_artifacts = delete_external_artifacts and config.get(
"services.async_urls_delete.enabled", False
"services.async_urls_delete.enabled", True
)
is_pipeline = "pipeline" in (project.system_tags or [])
project_ids = _ids_with_children([project_id])
project_ids, pipeline_ids, dataset_ids = _get_child_project_ids(project_id)
if not force:
query = dict(
project__in=project_ids, system_tags__nin=[EntityVisibility.archived.value]
)
if not is_pipeline:
if pipeline_ids:
active_controllers = Task.objects(
project__in=pipeline_ids,
type=TaskType.controller,
system_tags__nin=[EntityVisibility.archived.value],
).only("id")
if active_controllers:
raise errors.bad_request.ProjectHasPipelines(
"please archive all the controllers or use force=true",
id=project_id,
)
if dataset_ids:
datasets_with_data = Task.objects(
project__in=dataset_ids,
system_tags__nin=[EntityVisibility.archived.value],
).only("id")
if datasets_with_data:
raise errors.bad_request.ProjectHasDatasets(
"please delete all the dataset versions or use force=true",
id=project_id,
)
regular_projects = list(set(project_ids) - set(pipeline_ids) - set(dataset_ids))
if regular_projects:
for cls, error in (
(Task, errors.bad_request.ProjectHasTasks),
(Model, errors.bad_request.ProjectHasModels),
):
non_archived = cls.objects(**query).only("id")
non_archived = cls.objects(
project__in=regular_projects,
system_tags__nin=[EntityVisibility.archived.value],
).only("id")
if non_archived:
raise error("use force=true to delete", id=project_id)
else:
non_archived = Task.objects(**query, type=TaskType.controller).only("id")
if non_archived:
raise errors.bad_request.ProjectHasTasks(
"please archive all the runs inside the project", id=project_id
)
raise error("use force=true", id=project_id)
if not delete_contents:
for cls in (Model, Task):
updated_count = cls.objects(project__in=project_ids).update(project=None)
res = DeleteProjectResult(disassociated_tasks=updated_count)
disassociated = defaultdict(int)
for cls in ProjectBLL.child_classes:
disassociated[cls] = cls.objects(project__in=project_ids).update(
project=None
)
res = DeleteProjectResult(disassociated_tasks=disassociated[Task])
else:
deleted_models, model_event_urls, model_urls = _delete_models(
company=company, projects=project_ids
company=company, user=user, projects=project_ids
)
deleted_tasks, task_event_urls, artifact_urls = _delete_tasks(
company=company, projects=project_ids
company=company, user=user, projects=project_ids
)
event_urls = task_event_urls | model_event_urls
if delete_external_artifacts:
@@ -135,7 +217,9 @@ def delete_project(
return res, affected
def _delete_tasks(company: str, projects: Sequence[str]) -> Tuple[int, Set, Set]:
def _delete_tasks(
company: str, user: str, projects: Sequence[str]
) -> Tuple[int, Set, Set]:
"""
Delete only the task themselves and their non published version.
Child models under the same project are deleted separately.
@@ -146,14 +230,24 @@ def _delete_tasks(company: str, projects: Sequence[str]) -> Tuple[int, Set, Set]
if not tasks:
return 0, set(), set()
task_ids = {t.id for t in tasks}
Task.objects(parent__in=task_ids, project__nin=projects).update(parent=None)
Model.objects(task__in=task_ids, project__nin=projects).update(task=None)
task_ids = list({t.id for t in tasks})
now = datetime.utcnow()
Task.objects(parent__in=task_ids, project__nin=projects).update(
parent=None,
last_change=now,
last_changed_by=user,
)
Model.objects(task__in=task_ids, project__nin=projects).update(
task=None,
last_change=now,
last_changed_by=user,
)
event_urls, artifact_urls = set(), set()
event_urls = collect_debug_image_urls(company, task_ids) | collect_plot_image_urls(
company, task_ids
)
artifact_urls = set()
for task in tasks:
event_urls.update(collect_debug_image_urls(company, task.id))
event_urls.update(collect_plot_image_urls(company, task.id))
if task.execution and task.execution.artifacts:
artifact_urls.update(
{
@@ -163,15 +257,13 @@ def _delete_tasks(company: str, projects: Sequence[str]) -> Tuple[int, Set, Set]
}
)
event_bll.delete_multi_task_events(
company, list(task_ids), async_delete=async_events_delete
)
event_bll.delete_multi_task_events(company, task_ids)
deleted = tasks.delete()
return deleted, event_urls, artifact_urls
def _delete_models(
company: str, projects: Sequence[str]
company: str, user: str, projects: Sequence[str]
) -> Tuple[int, Set[str], Set[str]]:
"""
Delete project models and update the tasks from other projects
@@ -182,39 +274,54 @@ def _delete_models(
return 0, set(), set()
model_ids = list({m.id for m in models})
deleted = "__DELETED__"
Task._get_collection().update_many(
filter={
"project": {"$nin": projects},
"models.input.model": {"$in": model_ids},
},
update={"$set": {"models.input.$[elem].model": None}},
update={"$set": {"models.input.$[elem].model": deleted}},
array_filters=[{"elem.model": {"$in": model_ids}}],
upsert=False,
)
model_tasks = list({m.task for m in models if m.task})
if model_tasks:
now = datetime.utcnow()
# update published tasks
Task._get_collection().update_many(
filter={
"_id": {"$in": model_tasks},
"project": {"$nin": projects},
"models.output.model": {"$in": model_ids},
"status": TaskStatus.published,
},
update={
"$set": {
"models.output.$[elem].model": deleted,
"last_change": now,
"last_changed_by": user,
}
},
update={"$set": {"models.output.$[elem].model": None}},
array_filters=[{"elem.model": {"$in": model_ids}}],
upsert=False,
)
# update unpublished tasks
Task.objects(
id__in=model_tasks,
project__nin=projects,
status__ne=TaskStatus.published,
).update(
pull__models__output__model__in=model_ids,
set__last_change=now,
set__last_changed_by=user,
)
event_urls, model_urls = set(), set()
for m in models:
event_urls.update(collect_debug_image_urls(company, m.id))
event_urls.update(collect_plot_image_urls(company, m.id))
if m.uri:
model_urls.add(m.uri)
event_bll.delete_multi_task_events(
company, model_ids, async_delete=async_events_delete
event_urls = collect_debug_image_urls(company, model_ids) | collect_plot_image_urls(
company, model_ids
)
model_urls = {m.uri for m in models if m.uri}
event_bll.delete_multi_task_events(company, model_ids, model=True)
deleted = models.delete()
return deleted, event_urls, model_urls

View File

@@ -140,7 +140,12 @@ class ProjectQueries:
name: str,
include_subprojects: bool,
allow_public: bool = True,
pattern: str = None,
page: int = 0,
page_size: int = 500,
) -> ParamValues:
page = max(0, page)
page_size = max(1, page_size)
company_constraint = self._get_company_constraint(company_id, allow_public)
project_constraint = self._get_project_constraint(
project_ids, include_subprojects
@@ -160,7 +165,20 @@ class ProjectQueries:
if not last_updated_task:
return 0, []
redis_key = f"hyperparam_values_{company_id}_{'_'.join(project_ids)}_{section}_{name}_{allow_public}"
redis_key = "_".join(
str(part)
for part in (
"hyperparam_values",
company_id,
"_".join(project_ids),
section,
name,
allow_public,
pattern,
page,
page_size,
)
)
last_update = last_updated_task.last_update or datetime.utcnow()
cached_res = self._get_cached_param_values(
key=redis_key,
@@ -172,19 +190,27 @@ class ProjectQueries:
if cached_res:
return cached_res
max_values = config.get("services.tasks.hyperparam_values.max_count", 100)
pipeline = [
{
"$match": {
**company_constraint,
**project_constraint,
key_path: {"$exists": True},
match_condition = {
**company_constraint,
**project_constraint,
key_path: {"$exists": True},
}
if pattern:
match_condition["$expr"] = {
"$regexMatch": {
"input": f"${key_path}.value",
"regex": pattern,
"options": "i",
}
},
}
pipeline = [
{"$match": match_condition},
{"$project": {"value": f"${key_path}.value"}},
{"$group": {"_id": "$value"}},
{"$sort": {"_id": 1}},
{"$limit": max_values},
{"$skip": page * page_size},
{"$limit": page_size},
{
"$group": {
"_id": 1,
@@ -209,7 +235,11 @@ class ProjectQueries:
@classmethod
def get_unique_metric_variants(
cls, company_id, project_ids: Sequence[str], include_subprojects: bool
cls,
company_id,
project_ids: Sequence[str],
include_subprojects: bool,
model_metrics: bool = False,
):
pipeline = [
{
@@ -246,7 +276,8 @@ class ProjectQueries:
{"$sort": OrderedDict({"_id.metric": 1, "_id.variant": 1})},
]
result = Task.aggregate(pipeline)
entity_cls = Model if model_metrics else Task
result = entity_cls.aggregate(pipeline)
return [r["metrics"][0] for r in result]
@classmethod
@@ -306,7 +337,11 @@ class ProjectQueries:
key: str,
include_subprojects: bool,
allow_public: bool = True,
page: int = 0,
page_size: int = 500,
) -> ParamValues:
page = max(0, page)
page_size = max(1, page_size)
company_constraint = self._get_company_constraint(company_id, allow_public)
project_constraint = self._get_project_constraint(
project_ids, include_subprojects
@@ -326,7 +361,7 @@ class ProjectQueries:
if not last_updated_model:
return 0, []
redis_key = f"modelmetadata_values_{company_id}_{'_'.join(project_ids)}_{key}_{allow_public}"
redis_key = f"modelmetadata_values_{company_id}_{'_'.join(project_ids)}_{key}_{allow_public}_{page}_{page_size}"
last_update = last_updated_model.last_update or datetime.utcnow()
cached_res = self._get_cached_param_values(
key=redis_key, last_update=last_update
@@ -334,7 +369,6 @@ class ProjectQueries:
if cached_res:
return cached_res
max_values = config.get("services.models.metadata_values.max_count", 100)
pipeline = [
{
"$match": {
@@ -346,7 +380,8 @@ class ProjectQueries:
{"$project": {"value": f"${key_path}.value"}},
{"$group": {"_id": "$value"}},
{"$sort": {"_id": 1}},
{"$limit": max_values},
{"$skip": page * page_size},
{"$limit": page_size},
{
"$group": {
"_id": 1,

View File

@@ -14,14 +14,16 @@ def _get_project_depth(project_name: str) -> int:
return len(list(filter(None, project_name.split(name_separator))))
def _validate_project_name(project_name: str) -> Tuple[str, str]:
def _validate_project_name(project_name: str, raise_if_empty=True) -> Tuple[str, str]:
"""
Remove redundant '/' characters. Ensure that the project name is not empty
Return the cleaned up project name and location
"""
name_parts = list(filter(None, project_name.split(name_separator)))
name_parts = [p.strip() for p in project_name.split(name_separator) if p]
if not name_parts:
raise errors.bad_request.InvalidProjectName(name=project_name)
if raise_if_empty:
raise errors.bad_request.InvalidProjectName(name=project_name)
return "", ""
return name_separator.join(name_parts), name_separator.join(name_parts[:-1])
@@ -34,7 +36,7 @@ def _ensure_project(
If needed auto-create the project and all the missing projects in the path to it
Return the project
"""
name = name.strip(name_separator)
name, location = _validate_project_name(name, raise_if_empty=False)
if not name:
return None
@@ -43,7 +45,6 @@ def _ensure_project(
return project
now = datetime.utcnow()
name, location = _validate_project_name(name)
project = Project(
id=database.utils.id(),
user=user,
@@ -101,12 +102,15 @@ def _get_writable_project_from_name(
return qs.first()
ProjectsChildren = Mapping[str, Sequence[Project]]
def _get_sub_projects(
project_ids: Sequence[str],
_only: Sequence[str] = ("id", "path"),
search_hidden=True,
allowed_ids: Sequence[str] = None,
) -> Mapping[str, Sequence[Project]]:
) -> ProjectsChildren:
"""
Return the list of child projects of all the levels for the parent project ids
"""
@@ -140,8 +144,8 @@ def _ids_with_children(project_ids: Sequence[str]) -> Sequence[str]:
"""
Return project ids with the ids of all the subprojects
"""
subprojects = Project.objects(path__in=project_ids).only("id")
return list({*project_ids, *(child.id for child in subprojects)})
children_ids = Project.objects(path__in=project_ids).scalar("id")
return list({*project_ids, *children_ids})
def _update_subproject_names(
@@ -156,13 +160,17 @@ def _update_subproject_names(
Optionally update the paths
"""
updated = 0
now = datetime.utcnow()
for child in children:
child_suffix = name_separator.join(
child.name.split(name_separator)[len(old_name.split(name_separator)) :]
child.name.split(name_separator)[len(old_name.split(name_separator)):]
)
updates = {"name": name_separator.join((project.name, child_suffix))}
updates = {
"name": name_separator.join((project.name, child_suffix)),
"last_update": now,
}
if update_path:
updates["path"] = project.path + child.path[len(old_path) :]
updates["path"] = project.path + child.path[len(old_path):]
updated += child.update(upsert=False, **updates)
return updated
@@ -177,6 +185,7 @@ def _reposition_project_with_children(
project.name = name_separator.join(
filter(None, (new_location, project.name.split(name_separator)[-1]))
)
project.last_update = datetime.utcnow()
_save_under_parent(project, parent=parent)
moved = 1 + _update_subproject_names(

View File

@@ -1,6 +1,6 @@
from collections import defaultdict
from datetime import datetime
from typing import Callable, Sequence, Optional, Tuple
from typing import Sequence, Optional, Tuple, Union
from elasticsearch import Elasticsearch
from mongoengine import Q
@@ -16,6 +16,8 @@ from apiserver.database.errors import translate_errors_context
from apiserver.database.model.queue import Queue, Entry
log = config.logger(__file__)
MOVE_FIRST = "first"
MOVE_LAST = "last"
class QueueBLL(object):
@@ -133,7 +135,7 @@ class QueueBLL(object):
self.get_by_id(company_id=company_id, queue_id=queue_id, only=("id",))
return Queue.safe_update(company_id, queue_id, update_fields)
def delete(self, company_id: str, queue_id: str, force: bool) -> None:
def delete(self, company_id: str, user_id: str, queue_id: str, force: bool) -> None:
"""
Delete the queue
:raise errors.bad_request.InvalidQueueId: if the queue is not found
@@ -153,7 +155,13 @@ class QueueBLL(object):
task = Task.get_for_writing(
company=company_id,
id=item.task,
_only=["id", "status", "enqueue_status", "project"],
_only=[
"id",
"company",
"status",
"enqueue_status",
"project",
],
)
if not task:
continue
@@ -163,6 +171,8 @@ class QueueBLL(object):
new_status=task.enqueue_status or TaskStatus.created,
status_reason="Queue was deleted",
status_message="",
user_id=user_id,
force=True,
).execute(enqueue_status=None)
except Exception as ex:
log.exception(
@@ -233,6 +243,7 @@ class QueueBLL(object):
{
"name": w.id,
"ip": w.ip,
"key": w.key,
"task": w.task.to_struct() if w.task else None,
}
for w in queue_workers.get(item["id"], [])
@@ -318,46 +329,131 @@ class QueueBLL(object):
return len(entries_to_remove) if res else 0
def reposition_task(
self,
company_id: str,
queue_id: str,
task_id: str,
pos_func: Callable[[int], int],
self, company_id: str, queue_id: str, task_id: str, move_count: Union[int, str],
) -> int:
"""
Moves the task in the queue to the position calculated by pos_func
Returns the updated task position in the queue
"""
with translate_errors_context():
queue = self.get_queue_with_task(
def get_queue_and_task_position():
q = self.get_queue_with_task(
company_id=company_id, queue_id=queue_id, task_id=task_id
)
return q, next(i for i, e in enumerate(q.entries) if e.task == task_id)
position = next(i for i, e in enumerate(queue.entries) if e.task == task_id)
new_position = pos_func(position)
with translate_errors_context():
queue, position = get_queue_and_task_position()
if move_count == MOVE_FIRST:
new_position = 0
elif move_count == MOVE_LAST:
new_position = len(queue.entries) - 1
else:
new_position = position + move_count
if new_position == position:
return new_position
if new_position != position:
entry = queue.entries[position]
query = dict(id=queue_id, company=company_id)
updated = Queue.objects(entries__task=task_id, **query).update_one(
pull__entries=entry, last_update=datetime.utcnow()
)
if not updated:
raise errors.bad_request.RemovedDuringReposition(
task=task_id, **query
)
inst = {"$push": {"entries": {"$each": [entry.to_proper_dict()]}}}
if new_position >= 0:
inst["$push"]["entries"]["$position"] = new_position
res = Queue.objects(entries__task__ne=task_id, **query).update_one(
__raw__=inst
)
if not res:
raise errors.bad_request.FailedAddingDuringReposition(
task=task_id, **query
)
without_entry = {
"$filter": {
"input": "$entries",
"as": "entry",
"cond": {"$ne": ["$$entry.task", task_id]},
}
}
task_entry = {
"$filter": {
"input": "$entries",
"as": "entry",
"cond": {"$eq": ["$$entry.task", task_id]},
}
}
if move_count == MOVE_FIRST:
operations = [
{
"$set": {
"entries": {"$concatArrays": [task_entry, without_entry]}
}
}
]
elif move_count == MOVE_LAST:
operations = [
{
"$set": {
"entries": {"$concatArrays": [without_entry, task_entry]}
}
}
]
else:
operations = [
{
"$set": {
"new_pos": {
"$add": [
{"$indexOfArray": ["$entries.task", task_id]},
move_count,
]
},
"without_entry": without_entry,
"task_entry": task_entry,
}
},
{
"$set": {
"entries": {
"$switch": {
"branches": [
{
"case": {"$lte": ["$new_pos", 0]},
"then": {
"$concatArrays": [
"$task_entry",
"$without_entry",
]
},
},
{
"case": {
"$gte": [
"$new_pos",
{"$size": "$without_entry"},
]
},
"then": {
"$concatArrays": [
"$without_entry",
"$task_entry",
]
},
},
],
"default": {
"$concatArrays": [
{"$slice": ["$without_entry", "$new_pos"]},
"$task_entry",
{
"$slice": [
"$without_entry",
"$new_pos",
{"$size": "$without_entry"},
]
},
]
},
}
}
}
},
{"$unset": ["new_pos", "without_entry", "task_entry"]},
]
return new_position
updated = Queue.objects(
id=queue_id, company=company_id, entries__task=task_id
).update_one(__raw__=operations)
if not updated:
raise errors.bad_request.FailedAddingDuringReposition(task=task_id)
return get_queue_and_task_position()[1]
def count_entries(self, company: str, queue_id: str) -> Optional[int]:
res = next(

View File

@@ -80,7 +80,7 @@ class QueueMetrics:
logged = 0
for q in queues:
queue_doc = make_doc(q)
self.es.index(index=es_index, body=queue_doc)
self.es.index(index=es_index, document=queue_doc)
redis_key = _queue_metrics_key_pattern.format(queue=q.id)
redis.set(redis_key, json.dumps(queue_doc))
logged += 1

View File

@@ -8,8 +8,7 @@ from typing import Sequence, Optional
import dpath
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter, Retry
from apiserver.bll.query import Builder as QueryBuilder
from apiserver.bll.util import get_server_uuid

View File

@@ -0,0 +1,48 @@
from copy import copy
from boltons.cacheutils import cachedproperty
from clearml.backend_config.bucket_config import (
S3BucketConfigurations,
AzureContainerConfigurations,
GSBucketConfigurations,
)
from apiserver.config_repo import config
log = config.logger(__file__)
class StorageBLL:
default_aws_configs: S3BucketConfigurations = None
conf = config.get("services.storage_credentials")
@cachedproperty
def _default_aws_configs(self) -> S3BucketConfigurations:
return S3BucketConfigurations.from_config(self.conf.get("aws.s3"))
@cachedproperty
def _default_azure_configs(self) -> AzureContainerConfigurations:
return AzureContainerConfigurations.from_config(self.conf.get("azure.storage"))
@cachedproperty
def _default_gs_configs(self) -> GSBucketConfigurations:
return GSBucketConfigurations.from_config(self.conf.get("google.storage"))
def get_azure_settings_for_company(
self,
company_id: str,
) -> AzureContainerConfigurations:
return copy(self._default_azure_configs)
def get_gs_settings_for_company(
self,
company_id: str,
) -> GSBucketConfigurations:
return copy(self._default_gs_configs)
def get_aws_settings_for_company(
self,
company_id: str,
) -> S3BucketConfigurations:
return copy(self._default_aws_configs)

View File

@@ -1,6 +1,5 @@
from .task_bll import TaskBLL
from .utils import (
ChangeStatusRequest,
update_project_time,
validate_status_change,
)

View File

@@ -48,6 +48,7 @@ class Artifacts:
def add_or_update_artifacts(
cls,
company_id: str,
user_id: str,
task_id: str,
artifacts: Sequence[ApiArtifact],
force: bool,
@@ -63,12 +64,13 @@ class Artifacts:
f"set__execution__artifacts__{mongoengine_safe(name)}": value
for name, value in artifacts.items()
}
return update_task(task, update_cmds=update_cmds)
return update_task(task, user_id=user_id, update_cmds=update_cmds)
@classmethod
def delete_artifacts(
cls,
company_id: str,
user_id: str,
task_id: str,
artifact_ids: Sequence[ArtifactId],
force: bool,
@@ -83,4 +85,4 @@ class Artifacts:
f"unset__execution__artifacts__{id_}": 1 for id_ in set(artifact_ids)
}
return update_task(task, update_cmds=delete_cmds)
return update_task(task, user_id=user_id, update_cmds=delete_cmds)

View File

@@ -63,6 +63,7 @@ class HyperParams:
def delete_params(
cls,
company_id: str,
user_id: str,
task_id: str,
hyperparams: Sequence[HyperParamKey],
force: bool,
@@ -94,13 +95,17 @@ class HyperParams:
delete_cmds[f"unset__hyperparams__{section}__{name}"] = 1
return update_task(
task, update_cmds=delete_cmds, set_last_update=not properties_only
task,
user_id=user_id,
update_cmds=delete_cmds,
set_last_update=not properties_only,
)
@classmethod
def edit_params(
cls,
company_id: str,
user_id: str,
task_id: str,
hyperparams: Sequence[HyperParamItem],
replace_hyperparams: str,
@@ -129,7 +134,10 @@ class HyperParams:
] = value
return update_task(
task, update_cmds=update_cmds, set_last_update=not properties_only
task,
user_id=user_id,
update_cmds=update_cmds,
set_last_update=not properties_only,
)
@classmethod
@@ -201,6 +209,7 @@ class HyperParams:
def edit_configuration(
cls,
company_id: str,
user_id: str,
task_id: str,
configuration: Sequence[Configuration],
replace_configuration: bool,
@@ -219,11 +228,16 @@ class HyperParams:
for name, value in configuration.items():
update_cmds[f"set__configuration__{mongoengine_safe(name)}"] = value
return update_task(task, update_cmds=update_cmds)
return update_task(task, user_id=user_id, update_cmds=update_cmds)
@classmethod
def delete_configuration(
cls, company_id: str, task_id: str, configuration: Sequence[str], force: bool
cls,
company_id: str,
user_id: str,
task_id: str,
configuration: Sequence[str],
force: bool,
) -> int:
task = get_task_for_update(company_id=company_id, task_id=task_id, force=force)
@@ -232,4 +246,4 @@ class HyperParams:
for name in set(configuration)
}
return update_task(task, update_cmds=delete_cmds)
return update_task(task, user_id=user_id, update_cmds=delete_cmds)

View File

@@ -1,7 +1,7 @@
from datetime import timedelta, datetime
from time import sleep
from apiserver.bll.task import update_project_time
from apiserver.bll.util import update_project_time
from apiserver.config_repo import config
from apiserver.database.model.task.task import TaskStatus, Task
from apiserver.utilities.threads_manager import ThreadsManager
@@ -85,6 +85,7 @@ class NonResponsiveTasksWatchdog:
status_changed=now,
last_update=now,
last_change=now,
last_changed_by="__apiserver__",
)
if updated:
project_ids.add(task.project)

View File

@@ -7,11 +7,12 @@ from redis import StrictRedis
from six import string_types
import apiserver.database.utils as dbutils
from apiserver.apierrors import errors
from apiserver.apierrors import errors, APIError
from apiserver.apimodels.tasks import TaskInputModel
from apiserver.bll.queue import QueueBLL
from apiserver.bll.organization import OrgBLL, Tags
from apiserver.bll.project import ProjectBLL
from apiserver.bll.util import update_project_time
from apiserver.config_repo import config
from apiserver.database.errors import translate_errors_context
from apiserver.database.model.model import Model
@@ -30,17 +31,20 @@ from apiserver.database.model.task.task import (
TaskModelTypes,
)
from apiserver.database.model import EntityVisibility
from apiserver.database.utils import get_company_or_none_constraint, id as create_id
from apiserver.database.model.queue import Queue
from apiserver.database.utils import (
get_company_or_none_constraint,
id as create_id,
)
from apiserver.es_factory import es_factory
from apiserver.redis_manager import redman
from apiserver.service_repo import APICall
from apiserver.services.utils import validate_tags, escape_dict_field, escape_dict
from .artifacts import artifacts_prepare_for_save
from .param_utils import params_prepare_for_save
from .utils import (
ChangeStatusRequest,
update_project_time,
deleted_prefix,
get_last_metric_updates,
)
log = config.logger(__file__)
@@ -77,7 +81,11 @@ class TaskBLL:
@staticmethod
def get_by_id(
company_id, task_id, required_status=None, only_fields=None, allow_public=False,
company_id,
task_id,
required_status=None,
only_fields=None,
allow_public=False,
):
if only_fields:
if isinstance(only_fields, string_types):
@@ -128,16 +136,16 @@ class TaskBLL:
return list(q)
@staticmethod
def create(call: APICall, fields: dict):
identity = call.identity
def create(company: str, user: str, fields: dict):
now = datetime.utcnow()
return Task(
id=create_id(),
user=identity.user,
company=identity.company,
user=user,
company=company,
created=now,
last_update=now,
last_change=now,
last_changed_by=user,
**fields,
)
@@ -257,6 +265,16 @@ class TaskBLL:
not in [TaskSystemTags.development, EntityVisibility.archived.value]
]
def ensure_int_labels(execution: dict) -> dict:
if not execution:
return execution
model_labels = execution.get("model_labels")
if model_labels:
execution["model_labels"] = {k: int(v) for k, v in model_labels.items()}
return execution
parent_task = (
task.parent
if task.parent and not task.parent.startswith(deleted_prefix)
@@ -269,6 +287,7 @@ class TaskBLL:
created=now,
last_update=now,
last_change=now,
last_changed_by=user_id,
name=name or task.name,
comment=comment or task.comment,
parent=parent or parent_task,
@@ -280,7 +299,7 @@ class TaskBLL:
output=Output(destination=task.output.destination) if task.output else None,
models=Models(input=input_models or task.models.input),
container=escape_dict(container) or task.container,
execution=execution_dict,
execution=ensure_int_labels(execution_dict),
configuration=params_dict.get("configuration") or task.configuration,
hyperparams=params_dict.get("hyperparams") or task.hyperparams,
)
@@ -301,7 +320,7 @@ class TaskBLL:
org_bll.update_tags(
company_id,
Tags.Task,
project=new_task.project,
projects=[new_task.project],
tags=updated_tags,
system_tags=updated_system_tags,
)
@@ -344,6 +363,7 @@ class TaskBLL:
def set_last_update(
task_ids: Collection[str],
company_id: str,
user_id: str,
last_update: datetime,
**extra_updates,
):
@@ -364,6 +384,7 @@ class TaskBLL:
upsert=False,
last_update=last_update,
last_change=last_update,
last_changed_by=user_id,
**updates,
)
return count
@@ -372,6 +393,7 @@ class TaskBLL:
def update_statistics(
task_id: str,
company_id: str,
user_id: str,
last_update: datetime = None,
last_iteration: int = None,
last_iteration_max: int = None,
@@ -400,50 +422,21 @@ class TaskBLL:
elif last_iteration_max is not None:
extra_updates.update(max__last_iteration=last_iteration_max)
raw_updates = {}
if last_scalar_events is not None:
max_values = config.get("services.tasks.max_last_metrics", 2000)
total_metrics = set()
if max_values:
query = dict(id=task_id)
to_add = sum(len(v) for m, v in last_scalar_events.items())
if to_add <= max_values:
query[f"unique_metrics__{max_values-to_add}__exists"] = True
task = Task.objects(**query).only("unique_metrics").first()
if task and task.unique_metrics:
total_metrics = set(task.unique_metrics)
new_metrics = []
for metric_key, metric_data in last_scalar_events.items():
for variant_key, variant_data in metric_data.items():
metric = (
f"{variant_data.get('metric')}/{variant_data.get('variant')}"
)
if max_values:
if (
len(total_metrics) >= max_values
and metric not in total_metrics
):
continue
total_metrics.add(metric)
new_metrics.append(metric)
path = f"last_metrics__{metric_key}__{variant_key}"
for key, value in variant_data.items():
if key == "min_value":
extra_updates[f"min__{path}__min_value"] = value
elif key == "max_value":
extra_updates[f"max__{path}__max_value"] = value
elif key in ("metric", "variant", "value"):
extra_updates[f"set__{path}__{key}"] = value
if new_metrics:
extra_updates["add_to_set__unique_metrics"] = new_metrics
get_last_metric_updates(
task_id=task_id,
last_scalar_events=last_scalar_events,
raw_updates=raw_updates,
extra_updates=extra_updates,
)
if last_events is not None:
def events_per_type(metric_data: Dict[str, dict]) -> Dict[str, EventStats]:
def events_per_type(metric_data_: Dict[str, dict]) -> Dict[str, EventStats]:
return {
event_type: EventStats(last_update=event["timestamp"])
for event_type, event in metric_data.items()
for event_type, event in metric_data_.items()
}
metric_stats = {
@@ -454,28 +447,54 @@ class TaskBLL:
}
extra_updates["metric_stats"] = metric_stats
return TaskBLL.set_last_update(
ret = TaskBLL.set_last_update(
task_ids=[task_id],
company_id=company_id,
user_id=user_id,
last_update=last_update,
**extra_updates,
)
if ret and raw_updates:
Task.objects(id=task_id).update_one(__raw__=[{"$set": raw_updates}])
return ret
@staticmethod
def remove_task_from_all_queues(company_id: str, task_id: str) -> int:
return Queue.objects(company=company_id, entries__task=task_id).update(
pull__entries__task=task_id, last_update=datetime.utcnow()
)
@classmethod
def dequeue_and_change_status(
cls, task: Task, company_id: str, status_message: str, status_reason: str,
cls,
task: Task,
company_id: str,
user_id: str,
status_message: str,
status_reason: str,
remove_from_all_queues=False,
new_status=None,
):
try:
cls.dequeue(task, company_id)
except errors.bad_request.InvalidQueueOrTaskNotQueued:
cls.dequeue(task, company_id, silent_fail=True)
except APIError:
# dequeue may fail if the queue was deleted
pass
if remove_from_all_queues:
cls.remove_task_from_all_queues(company_id=company_id, task_id=task.id)
if task.status not in [TaskStatus.queued, TaskStatus.in_progress]:
return {"updated": 0}
return ChangeStatusRequest(
task=task,
new_status=task.enqueue_status or TaskStatus.created,
new_status=new_status or task.enqueue_status or TaskStatus.created,
status_reason=status_reason,
status_message=status_message,
user_id=user_id,
force=True,
).execute(enqueue_status=None)
@classmethod

View File

@@ -1,10 +1,11 @@
from datetime import datetime
from itertools import chain
from operator import attrgetter
from typing import Sequence, Set, Tuple
from typing import Sequence, Set, Tuple, Union
import attr
from boltons.iterutils import partition, bucketize, first
from boltons.iterutils import partition, bucketize, first, chunked_iter
from furl import furl
from mongoengine import NotUniqueError
from pymongo.errors import DuplicateKeyError
@@ -25,7 +26,6 @@ from apiserver.database.utils import id as db_id
log = config.logger(__file__)
event_bll = EventBLL()
async_events_delete = config.get("services.tasks.async_events_delete", False)
@attr.s(auto_attribs=True)
@@ -68,49 +68,74 @@ class CleanupResult:
)
def collect_plot_image_urls(company: str, task_or_model: str) -> Set[str]:
def collect_plot_image_urls(
company: str, task_or_model: Union[str, Sequence[str]]
) -> Set[str]:
urls = set()
next_scroll_id = None
while True:
events, next_scroll_id = event_bll.get_plot_image_urls(
company_id=company, task_id=task_or_model, scroll_id=next_scroll_id
)
if not events:
break
for event in events:
event_urls = event.get(PlotFields.source_urls)
if event_urls:
urls.update(set(event_urls))
task_ids = task_or_model if isinstance(task_or_model, list) else [task_or_model]
for tasks in chunked_iter(task_ids, 100):
next_scroll_id = None
while True:
events, next_scroll_id = event_bll.get_plot_image_urls(
company_id=company, task_ids=tasks, scroll_id=next_scroll_id
)
if not events:
break
for event in events:
event_urls = event.get(PlotFields.source_urls)
if event_urls:
urls.update(set(event_urls))
return urls
def collect_debug_image_urls(company: str, task_or_model: str) -> Set[str]:
def collect_debug_image_urls(
company: str, task_or_model: Union[str, Sequence[str]]
) -> Set[str]:
"""
Return the set of unique image urls
Uses DebugImagesIterator to make sure that we do not retrieve recycled urls
"""
after_key = None
urls = set()
while True:
res, after_key = event_bll.get_debug_image_urls(
company_id=company, task_id=task_or_model, after_key=after_key,
)
urls.update(res)
if not after_key:
break
task_ids = task_or_model if isinstance(task_or_model, list) else [task_or_model]
for tasks in chunked_iter(task_ids, 100):
after_key = None
while True:
res, after_key = event_bll.get_debug_image_urls(
company_id=company,
task_ids=tasks,
after_key=after_key,
)
urls.update(res)
if not after_key:
break
return urls
supported_storage_types = {
"https://": StorageType.fileserver,
"http://": StorageType.fileserver,
"s3://": StorageType.s3,
"azure://": StorageType.azure,
"gs://": StorageType.gs,
}
supported_storage_types.update(
{
p: StorageType.fileserver
for p in config.get(
"services.async_urls_delete.fileserver.url_prefixes",
["https://", "http://"],
)
}
)
def _schedule_for_delete(
company: str, user: str, task_id: str, urls: Set[str], can_delete_folders: bool,
company: str,
user: str,
task_id: str,
urls: Set[str],
can_delete_folders: bool,
) -> Set[str]:
urls_per_storage = bucketize(
urls,
@@ -129,7 +154,14 @@ def _schedule_for_delete(
for url in storage_urls:
folder = None
if delete_folders:
folder, _, _ = url.rpartition("/")
try:
parsed = furl(url)
if parsed.path and len(parsed.path.segments) > 1:
folder = parsed.remove(
args=True, fragment=True, path=parsed.path.segments[-1]
).url.rstrip("/")
except Exception as ex:
pass
to_delete = folder or url
if to_delete in scheduled_to_delete:
@@ -185,7 +217,7 @@ def cleanup_task(
task, force
)
delete_external_artifacts = delete_external_artifacts and config.get(
"services.async_urls_delete.enabled", False
"services.async_urls_delete.enabled", True
)
event_urls, artifact_urls, model_urls = set(), set(), set()
if return_file_urls or delete_external_artifacts:
@@ -203,8 +235,13 @@ def cleanup_task(
deleted_task_id = f"{deleted_prefix}{task.id}"
updated_children = 0
now = datetime.utcnow()
if update_children:
updated_children = Task.objects(parent=task.id).update(parent=deleted_task_id)
updated_children = Task.objects(parent=task.id).update(
parent=deleted_task_id,
last_change=now,
last_changed_by=user,
)
deleted_models = 0
updated_models = 0
@@ -212,37 +249,41 @@ def cleanup_task(
if not models:
continue
if delete_output_models and allow_delete:
model_ids = set(m.id for m in models if m.id not in in_use_model_ids)
for m_id in model_ids:
model_ids = list({m.id for m in models if m.id not in in_use_model_ids})
if model_ids:
if return_file_urls or delete_external_artifacts:
event_urls.update(collect_debug_image_urls(task.company, m_id))
event_urls.update(collect_plot_image_urls(task.company, m_id))
try:
event_bll.delete_task_events(
task.company,
m_id,
allow_locked=True,
model=True,
async_delete=async_events_delete,
)
except errors.bad_request.InvalidModelId as ex:
log.info(f"Error deleting events for the model {m_id}: {str(ex)}")
event_urls.update(collect_debug_image_urls(task.company, model_ids))
event_urls.update(collect_plot_image_urls(task.company, model_ids))
event_bll.delete_multi_task_events(
task.company,
model_ids,
model=True,
)
deleted_models += Model.objects(id__in=model_ids).delete()
deleted_models += Model.objects(id__in=list(model_ids)).delete()
if in_use_model_ids:
Model.objects(id__in=list(in_use_model_ids)).update(unset__task=1)
Model.objects(id__in=list(in_use_model_ids)).update(
unset__task=1,
set__last_change=now,
set__last_changed_by=user,
)
continue
if update_children:
updated_models += Model.objects(id__in=[m.id for m in models]).update(
task=deleted_task_id
task=deleted_task_id,
last_change=now,
last_changed_by=user,
)
else:
Model.objects(id__in=[m.id for m in models]).update(unset__task=1)
Model.objects(id__in=[m.id for m in models]).update(
unset__task=1,
set__last_change=now,
set__last_changed_by=user,
)
event_bll.delete_task_events(
task.company, task.id, allow_locked=force, async_delete=async_events_delete
)
event_bll.delete_task_events(task.company, task.id, allow_locked=force)
if delete_external_artifacts:
scheduled = _schedule_for_delete(
@@ -285,7 +326,8 @@ def verify_task_children_and_ouptuts(
model_fields = ["id", "ready", "uri"]
published_models, draft_models = partition(
Model.objects(task=task.id).only(*model_fields), key=attrgetter("ready"),
Model.objects(task=task.id).only(*model_fields),
key=attrgetter("ready"),
)
if not force and published_models:
raise errors.bad_request.TaskCannotBeDeleted(

View File

@@ -7,9 +7,9 @@ from apiserver.bll.task import (
TaskBLL,
validate_status_change,
ChangeStatusRequest,
update_project_time,
)
from apiserver.bll.task.task_cleanup import cleanup_task, CleanupResult
from apiserver.bll.util import update_project_time
from apiserver.config_repo import config
from apiserver.database.model import EntityVisibility
from apiserver.database.model.model import Model
@@ -23,6 +23,7 @@ from apiserver.database.model.task.task import (
Execution,
DEFAULT_LAST_ITERATION,
)
from apiserver.database.utils import get_options
from apiserver.utilities.dicts import nested_set
log = config.logger(__file__)
@@ -30,7 +31,11 @@ queue_bll = QueueBLL()
def archive_task(
task: Union[str, Task], company_id: str, status_message: str, status_reason: str,
task: Union[str, Task],
company_id: str,
user_id: str,
status_message: str,
status_reason: str,
) -> int:
"""
Deque and archive task
@@ -42,6 +47,7 @@ def archive_task(
company_id=company_id,
only=(
"id",
"company",
"execution",
"status",
"project",
@@ -52,7 +58,12 @@ def archive_task(
)
try:
TaskBLL.dequeue_and_change_status(
task, company_id, status_message, status_reason,
task,
company_id=company_id,
user_id=user_id,
status_message=status_message,
status_reason=status_reason,
remove_from_all_queues=True,
)
except APIError:
# dequeue may fail if the task was not enqueued
@@ -63,36 +74,73 @@ def archive_task(
status_reason=status_reason,
add_to_set__system_tags=EntityVisibility.archived.value,
last_change=datetime.utcnow(),
last_changed_by=user_id,
)
def unarchive_task(
task: str, company_id: str, status_message: str, status_reason: str,
task: str,
company_id: str,
user_id: str,
status_message: str,
status_reason: str,
) -> int:
"""
Unarchive task. Return 1 if successful
"""
task = TaskBLL.get_task_with_access(
task, company_id=company_id, only=("id",), requires_write_access=True,
task,
company_id=company_id,
only=("id",),
requires_write_access=True,
)
return task.update(
status_message=status_message,
status_reason=status_reason,
pull__system_tags=EntityVisibility.archived.value,
last_change=datetime.utcnow(),
last_changed_by=user_id,
)
def dequeue_task(
task_id: str, company_id: str, status_message: str, status_reason: str,
task_id: str,
company_id: str,
user_id: str,
status_message: str,
status_reason: str,
remove_from_all_queues: bool = False,
new_status=None,
) -> Tuple[int, dict]:
query = dict(id=task_id, company=company_id)
task = Task.get_for_writing(**query)
if new_status and new_status not in get_options(TaskStatus):
raise errors.bad_request.ValidationError(f"Invalid task status: {new_status}")
# get the task without write access to make sure that it actually exists
task = Task.get(
id=task_id,
company=company_id,
_only=(
"id",
"company",
"execution",
"status",
"project",
"enqueue_status",
),
include_public=True,
)
if not task:
raise errors.bad_request.InvalidTaskId(**query)
TaskBLL.remove_task_from_all_queues(company_id, task_id=task_id)
return 1, {"updated": 0}
res = TaskBLL.dequeue_and_change_status(
task, company_id, status_message=status_message, status_reason=status_reason,
task,
company_id=company_id,
user_id=user_id,
status_message=status_message,
status_reason=status_reason,
remove_from_all_queues=remove_from_all_queues,
new_status=new_status,
)
return 1, res
@@ -100,6 +148,7 @@ def dequeue_task(
def enqueue_task(
task_id: str,
company_id: str,
user_id: str,
queue_id: str,
status_message: str,
status_reason: str,
@@ -139,6 +188,7 @@ def enqueue_task(
status_message=status_message,
allow_same_state_transition=False,
force=force,
user_id=user_id,
).execute(enqueue_status=task.status)
try:
@@ -151,6 +201,7 @@ def enqueue_task(
new_status=task.status,
force=True,
status_reason="failed enqueueing",
user_id=user_id,
).execute(enqueue_status=None)
raise
@@ -220,8 +271,10 @@ def delete_task(
TaskBLL.dequeue_and_change_status(
task,
company_id=company_id,
user_id=user_id,
status_message=status_message,
status_reason=status_reason,
remove_from_all_queues=True,
)
except APIError:
# dequeue may fail if the task was not enqueued
@@ -240,6 +293,7 @@ def delete_task(
if move_to_trash:
# make sure that whatever changes were done to the task are saved
# the task itself will be deleted later in the move_tasks_to_trash operation
task.last_update = datetime.utcnow()
task.save()
else:
task.delete()
@@ -274,6 +328,8 @@ def reset_task(
# dequeue may fail if the task was not enqueued
pass
TaskBLL.remove_task_from_all_queues(company_id=company_id, task_id=task.id)
cleaned_up = cleanup_task(
company=company_id,
user=user_id,
@@ -296,11 +352,17 @@ def reset_task(
unset__output__error=1,
unset__last_worker=1,
unset__last_worker_report=1,
unset__started=1,
unset__completed=1,
unset__published=1,
unset__active_duration=1,
unset__enqueue_status=1,
)
if clear_all:
updates.update(
set__execution=Execution(), unset__script=1,
set__execution=Execution(),
unset__script=1,
)
else:
updates.update(unset__execution__queue=1)
@@ -319,12 +381,8 @@ def reset_task(
force=force,
status_reason="reset",
status_message="reset",
user_id=user_id,
).execute(
started=None,
completed=None,
published=None,
active_duration=None,
enqueue_status=None,
**updates,
)
@@ -334,8 +392,9 @@ def reset_task(
def publish_task(
task_id: str,
company_id: str,
user_id: str,
force: bool,
publish_model_func: Callable[[str, str], Any] = None,
publish_model_func: Callable[[str, str, str], Any] = None,
status_message: str = "",
status_reason: str = "",
) -> dict:
@@ -363,7 +422,7 @@ def publish_task(
.first()
)
if model and not model.ready:
publish_model_func(model.id, company_id)
publish_model_func(model.id, company_id, user_id)
# set task status to published, and update (or set) it's new output (view and models)
return ChangeStatusRequest(
@@ -372,6 +431,7 @@ def publish_task(
force=force,
status_reason=status_reason,
status_message=status_message,
user_id=user_id,
).execute(published=datetime.utcnow(), output=output)
except Exception as ex:
@@ -384,7 +444,12 @@ def publish_task(
def stop_task(
task_id: str, company_id: str, user_name: str, status_reason: str, force: bool,
task_id: str,
company_id: str,
user_id: str,
user_name: str,
status_reason: str,
force: bool,
) -> dict:
"""
Stop a running task. Requires task status 'in_progress' and
@@ -446,4 +511,5 @@ def stop_task(
status_reason=status_reason,
status_message=status_message,
force=force,
user_id=user_id,
).execute()

View File

@@ -1,12 +1,13 @@
from datetime import datetime
from typing import Sequence, Union
import attr
import six
from apiserver.apierrors import errors
from apiserver.bll.util import update_project_time
from apiserver.config_repo import config
from apiserver.database.errors import translate_errors_context
from apiserver.database.model.project import Project
from apiserver.database.model.model import Model
from apiserver.database.model.task.task import Task, TaskStatus, TaskSystemTags
from apiserver.database.utils import get_options
from apiserver.utilities.attrs import typed_attrs
@@ -26,6 +27,7 @@ class ChangeStatusRequest(object):
force = attr.ib(type=bool, default=False)
allow_same_state_transition = attr.ib(type=bool, default=True)
current_status_override = attr.ib(default=None)
user_id = attr.ib(type=str, default=None)
def execute(self, **kwargs):
current_status = self.current_status_override or self.task.status
@@ -44,6 +46,7 @@ class ChangeStatusRequest(object):
status_changed=now,
last_update=now,
last_change=now,
last_changed_by=self.user_id,
)
if self.new_status == TaskStatus.queued:
@@ -154,16 +157,6 @@ def get_possible_status_changes(current_status):
return possible
def update_project_time(project_ids: Union[str, Sequence[str]]):
if not project_ids:
return
if isinstance(project_ids, str):
project_ids = [project_ids]
return Project.objects(id__in=project_ids).update(last_update=datetime.utcnow())
def get_task_for_update(
company_id: str, task_id: str, allow_all_statuses: bool = False, force: bool = False
) -> Task:
@@ -187,9 +180,88 @@ def get_task_for_update(
return task
def update_task(task: Task, update_cmds: dict, set_last_update: bool = True):
def update_task(
task: Task, user_id: str, update_cmds: dict, set_last_update: bool = True
):
now = datetime.utcnow()
last_updates = dict(last_change=now)
last_updates = dict(last_change=now, last_changed_by=user_id)
if set_last_update:
last_updates.update(last_update=now)
return task.update(**update_cmds, **last_updates)
def get_last_metric_updates(
task_id: str,
last_scalar_events: dict,
raw_updates: dict,
extra_updates: dict,
model_events: bool = False,
):
max_values = config.get("services.tasks.max_last_metrics", 2000)
total_metrics = set()
if max_values:
query = dict(id=task_id)
to_add = sum(len(v) for m, v in last_scalar_events.items())
if to_add <= max_values:
query[f"unique_metrics__{max_values - to_add}__exists"] = True
db_cls = Model if model_events else Task
task = db_cls.objects(**query).only("unique_metrics").first()
if task and task.unique_metrics:
total_metrics = set(task.unique_metrics)
new_metrics = []
def add_last_metric_conditional_update(
metric_path: str, metric_value, iter_value: int, is_min: bool
):
"""
Build an aggregation for an atomic update of the min or max value and the corresponding iteration
"""
if is_min:
field_prefix = "min"
op = "$gt"
else:
field_prefix = "max"
op = "$lt"
value_field = f"{metric_path}__{field_prefix}_value".replace("__", ".")
condition = {
"$or": [
{"$lte": [f"${value_field}", None]},
{op: [f"${value_field}", metric_value]},
]
}
raw_updates[value_field] = {
"$cond": [condition, metric_value, f"${value_field}"]
}
value_iteration_field = f"{metric_path}__{field_prefix}_value_iteration".replace(
"__", "."
)
raw_updates[value_iteration_field] = {
"$cond": [condition, iter_value, f"${value_iteration_field}"]
}
for metric_key, metric_data in last_scalar_events.items():
for variant_key, variant_data in metric_data.items():
metric = f"{variant_data.get('metric')}/{variant_data.get('variant')}"
if max_values:
if len(total_metrics) >= max_values and metric not in total_metrics:
continue
total_metrics.add(metric)
new_metrics.append(metric)
path = f"last_metrics__{metric_key}__{variant_key}"
for key, value in variant_data.items():
if key in ("min_value", "max_value"):
add_last_metric_conditional_update(
metric_path=path,
metric_value=value,
iter_value=variant_data.get(f"{key}_iter", 0),
is_min=(key == "min_value"),
)
elif key in ("metric", "variant", "value"):
extra_updates[f"set__{path}__{key}"] = value
if new_metrics:
extra_updates["add_to_set__unique_metrics"] = new_metrics

View File

@@ -1,76 +1,24 @@
import functools
import itertools
from concurrent.futures.thread import ThreadPoolExecutor
from datetime import datetime
from typing import (
Optional,
Callable,
Dict,
Any,
Set,
Iterable,
Tuple,
Sequence,
TypeVar,
Union,
)
from boltons import iterutils
from apiserver.apierrors import APIError
from apiserver.database.model import AttributedDocument
from apiserver.database.model.project import Project
from apiserver.database.model.settings import Settings
class SetFieldsResolver:
"""
The class receives set fields dictionary
and for the set fields that require 'min' or 'max'
operation replace them with a simple set in case the
DB document does not have these fields set
"""
SET_MODIFIERS = ("min", "max")
def __init__(self, set_fields: Dict[str, Any]):
self.orig_fields = {}
self.fields = {}
self.add_fields(**set_fields)
def add_fields(self, **set_fields: Any):
self.orig_fields.update(set_fields)
self.fields.update(
{
f: fname
for f, modifier, dunder, fname in (
(f,) + f.partition("__") for f in set_fields.keys()
)
if dunder and modifier in self.SET_MODIFIERS
}
)
def _get_updated_name(self, doc: AttributedDocument, name: str) -> str:
if name in self.fields and doc.get_field_value(self.fields[name]) is None:
return self.fields[name]
return name
def get_fields(self, doc: AttributedDocument):
"""
For the given document return the set fields instructions
with min/max operations replaced with a single set in case
the document does not have the field set
"""
return {
self._get_updated_name(doc, name): value
for name, value in self.orig_fields.items()
}
def get_names(self) -> Set[str]:
"""
Returns the names of the fields that had min/max modifiers
in the format suitable for projection (dot separated)
"""
return set(name.replace("__", ".") for name in self.fields.values())
@functools.lru_cache()
def get_server_uuid() -> Optional[str]:
return Settings.get_by_key("server.uuid")
@@ -132,3 +80,13 @@ def run_batch_operation(
}
)
return results, failures
def update_project_time(project_ids: Union[str, Sequence[str]]):
if not project_ids:
return
if isinstance(project_ids, str):
project_ids = [project_ids]
return Project.objects(id__in=project_ids).update(last_update=datetime.utcnow())

View File

@@ -5,13 +5,13 @@ from typing import Sequence, Set, Optional
import attr
import elasticsearch.helpers
from boltons.iterutils import partition
from boltons.iterutils import partition, chunked_iter
from pyhocon import ConfigTree
from apiserver.es_factory import es_factory
from apiserver.apierrors import APIError
from apiserver.apierrors.errors import bad_request, server_error
from apiserver.apimodels.workers import (
DEFAULT_TIMEOUT,
IdNameEntry,
WorkerEntry,
StatusReportRequest,
@@ -30,12 +30,14 @@ from apiserver.redis_manager import redman
from apiserver.tools import safe_get
from .stats import WorkerStats
log = config.logger(__file__)
class WorkerBLL:
def __init__(self, es=None, redis=None):
self.es_client = es or es_factory.connect("workers")
self.config = config.get("services.workers", ConfigTree())
self.redis = redis or redman.connection("workers")
self._stats = WorkerStats(self.es_client)
@@ -68,7 +70,7 @@ class WorkerBLL:
"""
key = WorkerBLL._get_worker_key(company_id, user_id, worker)
timeout = timeout or DEFAULT_TIMEOUT
timeout = timeout or int(self.config.get("default_worker_timeout_sec", 10 * 60))
queues = queues or []
with translate_errors_context():
@@ -141,8 +143,6 @@ class WorkerBLL:
try:
entry.ip = ip
now = datetime.utcnow()
entry.last_activity_time = now
if tags is not None:
entry.tags = tags
@@ -150,15 +150,16 @@ class WorkerBLL:
entry.system_tags = system_tags
if report.machine_stats:
self._log_stats_to_es(
self.log_stats_to_es(
company_id=company_id,
company_name=entry.company.name,
worker=entry.key,
worker_id=report.worker,
timestamp=report.timestamp,
task=report.task,
machine_stats=report.machine_stats,
)
now = datetime.utcnow()
entry.last_activity_time = now
entry.queue = report.queue
if report.queues:
@@ -175,6 +176,7 @@ class WorkerBLL:
last_worker_report=now,
last_update=now,
last_change=now,
last_changed_by=user_id,
)
# modify(new=True, ...) returns the modified object
task = Task.objects(**query).modify(new=True, **update)
@@ -200,6 +202,24 @@ class WorkerBLL:
finally:
self._save_worker(entry)
def get_count(
self,
company_id: str,
last_seen: Optional[int] = None,
tags: Sequence[str] = None,
system_tags: Sequence[str] = None,
):
if not last_seen:
return len(
self._get_keys(company_id, user_tags=tags, system_tags=system_tags)
)
return len(
self.get_all(
company_id, last_seen=last_seen, tags=tags, system_tags=system_tags
)
)
def get_all(
self,
company_id: str,
@@ -235,18 +255,15 @@ class WorkerBLL:
tags: Sequence[str] = None,
system_tags: Sequence[str] = None,
) -> Sequence[WorkerResponseEntry]:
helpers = list(
map(
WorkerConversionHelper.from_worker_entry,
self.get_all(
company_id=company_id,
last_seen=last_seen,
tags=tags,
system_tags=system_tags,
),
helpers = [
WorkerConversionHelper.from_worker_entry(entry)
for entry in self.get_all(
company_id=company_id,
last_seen=last_seen,
tags=tags,
system_tags=system_tags,
)
)
]
task_ids = set(filter(None, (helper.task_id for helper in helpers)))
all_queues = set(
@@ -265,9 +282,7 @@ class WorkerBLL:
}
},
]
queues_info = {
res["_id"]: res for res in Queue.objects.aggregate(projection)
}
queues_info = {res["_id"]: res for res in Queue.aggregate(projection)}
task_ids = task_ids.union(
filter(
None,
@@ -396,15 +411,16 @@ class WorkerBLL:
msg = "Failed saving worker entry"
log.exception(msg)
def _get(
def _get_keys(
self,
company: str,
user: str = "*",
worker_id: str = "*",
user_tags: Sequence[str] = None,
system_tags: Sequence[str] = None,
) -> Sequence[WorkerEntry]:
"""Get worker entries matching the company and user, worker patterns"""
) -> Sequence[bytes]:
if not (user_tags or system_tags):
match = self._get_worker_key(company, user, "*")
return list(self.redis.scan_iter(match))
def filter_by_user(in_keys: Set[bytes]) -> Set[bytes]:
if user == "*":
@@ -412,67 +428,79 @@ class WorkerBLL:
user_bytes = user.encode()
return {k for k in in_keys if user_bytes in k}
if user_tags or system_tags:
worker_keys = set()
for tags, tags_field in (
(user_tags, "tags"),
(system_tags, "systemtags"),
):
if not tags:
continue
timestamp = int(time())
include, exclude = partition(tags, key=lambda x: x[0] != "-")
if include:
tagged_workers = set()
for tag in include:
tagged_workers_key = self._get_tagged_workers_key(
company, tags_field, tag
)
self.redis.zremrangebyscore(
tagged_workers_key, min=0, max=timestamp
)
tagged_workers.update(
self.redis.zrange(tagged_workers_key, 0, -1)
)
tagged_workers = filter_by_user(tagged_workers)
worker_keys = (
worker_keys.intersection(tagged_workers)
if worker_keys
else tagged_workers
worker_keys = set()
for tags, tags_field in (
(user_tags, "tags"),
(system_tags, "systemtags"),
):
if not tags:
continue
timestamp = int(time())
include, exclude = partition(tags, key=lambda x: x[0] != "-")
if include:
tagged_workers = set()
for tag in include:
tagged_workers_key = self._get_tagged_workers_key(
company, tags_field, tag
)
self.redis.zremrangebyscore(
tagged_workers_key, min=0, max=timestamp
)
tagged_workers.update(self.redis.zrange(tagged_workers_key, 0, -1))
tagged_workers = filter_by_user(tagged_workers)
worker_keys = (
worker_keys.intersection(tagged_workers)
if worker_keys
else tagged_workers
)
if not worker_keys:
return []
if exclude:
if not worker_keys:
all_workers_key = self._get_all_workers_key(company)
self.redis.zremrangebyscore(all_workers_key, min=0, max=timestamp)
worker_keys.update(self.redis.zrange(all_workers_key, 0, -1))
worker_keys = filter_by_user(worker_keys)
if not worker_keys:
return []
for tag in exclude:
tagged_workers_key = self._get_tagged_workers_key(
company, tags_field, tag[1:]
)
self.redis.zremrangebyscore(
tagged_workers_key, min=0, max=timestamp
)
worker_keys.difference_update(
self.redis.zrange(tagged_workers_key, 0, -1)
)
if not worker_keys:
return []
if exclude:
if not worker_keys:
all_workers_key = self._get_all_workers_key(company)
self.redis.zremrangebyscore(
all_workers_key, min=0, max=timestamp
)
worker_keys.update(self.redis.zrange(all_workers_key, 0, -1))
worker_keys = filter_by_user(worker_keys)
if not worker_keys:
return []
for tag in exclude:
tagged_workers_key = self._get_tagged_workers_key(
company, tags_field, tag[1:]
)
self.redis.zremrangebyscore(
tagged_workers_key, min=0, max=timestamp
)
worker_keys.difference_update(
self.redis.zrange(tagged_workers_key, 0, -1)
)
if not worker_keys:
return []
else:
match = self._get_worker_key(company, user, "*")
worker_keys = self.redis.scan_iter(match)
return list(worker_keys)
def _get(
self,
company: str,
user: str = "*",
user_tags: Sequence[str] = None,
system_tags: Sequence[str] = None,
) -> Sequence[WorkerEntry]:
"""Get worker entries matching the company and user, worker patterns"""
entries = []
for key in worker_keys:
data = self.redis.get(key)
for keys in chunked_iter(
self._get_keys(
company, user=user, user_tags=user_tags, system_tags=system_tags
),
1000,
):
data = self.redis.mget(keys)
if data:
entries.append(WorkerEntry.from_json(data))
entries.extend(WorkerEntry.from_json(d) for d in data if d)
return entries
@@ -481,18 +509,17 @@ class WorkerBLL:
"""Get the index name suffix for storing current month data"""
return datetime.utcnow().strftime("%Y-%m")
def _log_stats_to_es(
def log_stats_to_es(
self,
company_id: str,
company_name: str,
worker: str,
worker_id: str,
timestamp: int,
task: str,
machine_stats: MachineStats,
) -> bool:
) -> int:
"""
Actually writing the worker statistics to Elastic
:return: True if successful, False otherwise
:return: The amount of logged documents
"""
es_index = (
f"{self._stats.worker_stats_prefix_for_company(company_id)}"
@@ -504,8 +531,7 @@ class WorkerBLL:
_index=es_index,
_source=dict(
timestamp=timestamp,
worker=worker,
company=company_name,
worker=worker_id,
task=task,
category=category,
metric=metric,
@@ -530,7 +556,7 @@ class WorkerBLL:
es_res = elasticsearch.helpers.bulk(self.es_client, actions)
added, errors = es_res[:2]
return (added == len(actions)) and not errors
return added
@attr.s(auto_attribs=True)

View File

@@ -215,6 +215,10 @@ class WorkerStats:
"date_histogram": {
"field": "timestamp",
"fixed_interval": f"{interval}s",
"extended_bounds": {
"min": int(from_date) * 1000,
"max": int(to_date) * 1000,
}
},
"aggs": {"workers_count": {"cardinality": {"field": "worker"}}},
}

View File

@@ -41,10 +41,6 @@
# controls whether FieldDoesNotExist exception will be raised for any extra attribute existing in stored data
# but not declared in a data model
strict: false
aggregate {
allow_disk_use: true
}
}
elastic {
@@ -117,6 +113,10 @@
# Timeout in seconds on task status update. If exceeded
# then task can be stopped without communicating to the worker
task_update_timeout: 600
# Timeout in seconds for worker registration (or status report). If a worker did not report for this long,
# it is discarded from the server's table
default_timeout: 600
}
check_for_updates {

View File

@@ -23,6 +23,11 @@
user_secret: "yfc8KQo*GMXb*9p((qcYC7ByFIpF7I&4VH3BfUYXH%o9vX1ZUZQEEw1Inc)S"
revoke_in_fixed_mode: true
}
services_agent {
role: "admin"
user_key: "P4BMJA7RK3TKBXGSY8OAA1FA8TOD11"
user_secret: "9LsgSfa0SYz0zli1_c500ZcLqanre2xkWOpepyt1w-BKK3_DKPHrtoj3JSHvyy8bIi0"
}
tests {
role: "user"
display_name: "Default User"

View File

@@ -2,3 +2,8 @@ max_page_size: 500
# expiration time in seconds for the redis scroll states in get_many family of apis
scroll_state_expiration_seconds: 600
allow_disk_use {
sort: true
aggregate: true
}

View File

@@ -1,6 +1,6 @@
# if set to True then on task delete/reset external file urls for know storage types are scheduled for async delete
# if set to true then on task delete/reset external file urls for known storage types are scheduled for async delete
# otherwise they are returned to a client for the client side delete
enabled: false
enabled: true
max_retries: 3
retry_timeout_sec: 60
@@ -9,4 +9,4 @@ fileserver {
# Can be in the form <schema>://host:port/path or /path
url_prefixes: ["https://files.community-master.hosted.allegro.ai/"]
timeout_sec: 300
}
}

View File

@@ -32,6 +32,8 @@ events_retrieval {
max_raw_scalars_size: 200000
scroll_id_key: "cTN5VEtWEC6QrHvUl0FTx9kNyO0CcCK1p57akxma"
multi_plots_batch_size: 1000
}
# if set then plot str will be checked for the valid json on plot add

View File

@@ -1,7 +1,4 @@
metadata_values {
# maximal amount of distinct model values to retrieve
max_count: 100
# cache ttl sec
cache_ttl_sec: 86400
}

View File

@@ -1,3 +1,9 @@
tags_cache {
expiration_seconds: 3600
}
download {
redis_timeout_sec: 300
batch_size: 500
max_download_items: 50000
max_project_name_length: 60
}

View File

@@ -0,0 +1,53 @@
aws {
s3 {
# S3 credentials, used for read/write access by various SDK elements
# default, used for any bucket not specified below
key: ""
secret: ""
region: ""
use_credentials_chain: false
# Additional ExtraArgs passed to boto3 when uploading files. Can also be set per-bucket under "credentials".
extra_args: {}
credentials: [
# specifies key/secret credentials to use when handling s3 urls (read or write)
# {
# bucket: "my-bucket-name"
# key: "my-access-key"
# secret: "my-secret-key"
# },
{
# This will apply to all buckets in this host (unless key/value is specifically provided for a given bucket)
host: "localhost:9000"
key: "evg_user"
secret: "evg_pass"
multipart: false
secure: false
}
]
}
}
google.storage {
# Default project and credentials file
# Will be used when no bucket configuration is found
// project: "clearml"
// credentials_json: "/path/to/credentials.json"
//
// # Specific credentials per bucket and sub directory
// credentials = [
// {
// bucket: "my-bucket"
// subdir: "path/in/bucket" # Not required
// project: "clearml"
// credentials_json: "/path/to/credentials.json"
// },
// ]
}
azure.storage {
# containers: [
# {
# account_name: "clearml"
# account_key: "secret"
# # container_name:
# }
# ]
}

View File

@@ -11,9 +11,6 @@ non_responsive_tasks_watchdog {
multi_task_histogram_limit: 100
hyperparam_values {
# maximal amount of distinct hyperparam values to retrieve
max_count: 100
# max allowed outdate time for the cashed result
cache_allowed_outdate_sec: 60
@@ -26,4 +23,6 @@ hyperparam_values {
max_last_metrics: 2000
# if set then call to tasks.delete/cleanup does not wait for ES events deletion
async_events_delete: false
async_events_delete: true
# do not use async_delete if the deleted task has amount of events lower than this threshold
async_events_delete_threshold: 100000

View File

@@ -16,7 +16,7 @@ from mongoengine.errors import (
LookUpError,
InvalidQueryError,
)
from pymongo.errors import PyMongoError, NotMasterError
from pymongo.errors import PyMongoError, NotPrimaryError
from apiserver.apierrors import errors
@@ -198,7 +198,7 @@ def translate_errors_context(message=None, **kwargs):
MongoEngineErrorsHandler.invalid_query_error(e, message, **kwargs)
except PyMongoError as e:
raise errors.server_error.InternalError(message, err=str(e))
except NotMasterError as e:
except NotPrimaryError as e:
raise errors.server_error.InternalError(message, err=str(e))
except MakeGetAllQueryError as e:
raise errors.bad_request.ValidationError(e.error, field=e.field)

View File

@@ -1,5 +1,6 @@
import re
from collections import namedtuple
from collections import namedtuple, defaultdict
from datetime import datetime
from functools import reduce, partial
from typing import (
Collection,
@@ -11,17 +12,18 @@ from typing import (
Mapping,
Any,
Callable,
Dict,
List,
Generator,
)
import attr
from boltons.iterutils import first, partition
from dateutil.parser import parse as parse_datetime
from mongoengine import Q, Document, ListField, StringField, IntField
from mongoengine import Q, Document, ListField, StringField, IntField, QuerySet
from pymongo.command_cursor import CommandCursor
from apiserver.apierrors import errors
from apiserver.apierrors import errors, APIError
from apiserver.apierrors.base import BaseError
from apiserver.apierrors.errors.bad_request import FieldsValueError
from apiserver.bll.redis_cache_manager import RedisCacheManager
from apiserver.config_repo import config
from apiserver.database import Database
@@ -39,7 +41,7 @@ from apiserver.redis_manager import redman
from apiserver.utilities.dicts import project_dict, exclude_fields_from_dict
log = config.logger("dbmodel")
mongo_conf = config.get("services._mongo")
ACCESS_REGEX = re.compile(r"^(?P<prefix>>=|>|<=|<)?(?P<value>.*)$")
ACCESS_MODIFIER = {">=": "gte", ">": "gt", "<=": "lte", "<": "lt"}
@@ -132,84 +134,135 @@ class GetMixin(PropsMixin):
self.range_fields = range_fields
self.pattern_fields = pattern_fields
class ListFieldBucketHelper:
class NewListFieldBucketHelper:
op_prefix = "__$"
_legacy_exclude_prefix = "-"
_legacy_exclude_mongo_op = "nin"
default_mongo_op = "in"
_ops = {
# op -> (mongo_op, sticky)
"not": ("nin", False),
"nop": (default_mongo_op, False),
"all": ("all", True),
"and": ("all", True),
"any": (default_mongo_op, True),
"or": (default_mongo_op, True),
_unary_operators = {
"__$not": False,
}
_reset_operator = "__$nop"
_operators = {
"__$all": Q.AND,
"__$and": Q.AND,
"__$any": Q.OR,
"__$or": Q.OR,
}
default_operator = Q.OR
mongo_modifiers = {
# not_all modifier currently not supported due to the backwards compatibility
Q.AND: {True: "all", False: "nin"},
Q.OR: {True: "in", False: "nin"},
}
def __init__(self, legacy=False):
self._current_op = None
self._sticky = False
self._support_legacy = legacy
@attr.s(auto_attribs=True)
class Term:
operator: str = None
reset: bool = False
include: bool = True
value: str = None
def __init__(self, field: str, data: Sequence[str], legacy=False):
self._field = field
self.allow_empty = False
self.global_operator = None
self.actions = defaultdict(list)
self.explicit_operator = False
def _get_op(self, v: str, translate: bool = False) -> Optional[str]:
op = (
v[len(self.op_prefix) :] if v and v.startswith(self.op_prefix) else None
)
if translate:
tup = self._ops.get(op, None)
return tup[0] if tup else None
return op
def _key(self, v) -> Optional[Union[str, bool]]:
if v is None:
self.allow_empty = True
return None
op = self._get_op(v)
if op is not None:
# operator - set state and return None
self._current_op, self._sticky = self._ops.get(
op, (self.default_mongo_op, self._sticky)
)
return None
elif self._current_op:
current_op = self._current_op
if not self._sticky:
self._current_op = None
return current_op
elif self._support_legacy and v.startswith(self._legacy_exclude_prefix):
self._current_op = None
return False
return self.default_mongo_op
def get_global_op(self, data: Sequence[str]) -> int:
op_to_res = {
"in": Q.OR,
"all": Q.AND,
}
data = (x for x in data if x is not None)
first_op = (
self._get_op(next(data, ""), translate=True) or self.default_mongo_op
)
return op_to_res.get(first_op, self.default_mongo_op)
def get_actions(self, data: Sequence[str]) -> Dict[str, List[Union[str, None]]]:
actions = {}
for val in data:
key = self._key(val)
if key is None:
self._support_legacy = legacy
current_context = self.default_operator
for d in self._get_next_term(data):
if d.operator is not None:
current_context = d.operator
self._support_legacy = False
if self.global_operator is None:
self.global_operator = d.operator
self.explicit_operator = True
continue
elif self._support_legacy and key is False:
key = self._legacy_exclude_mongo_op
val = val[len(self._legacy_exclude_prefix) :]
actions.setdefault(key, []).append(val)
return actions
if self.global_operator is None:
self.global_operator = self.default_operator
if d.reset:
current_context = self.default_operator
self._support_legacy = legacy
continue
if d.value is None:
self.allow_empty = True
continue
self.actions[self.mongo_modifiers[current_context][d.include]].append(
d.value
)
if self.global_operator is None:
self.global_operator = self.default_operator
def _get_next_term(self, data: Sequence[str]) -> Generator[Term, None, None]:
unary_operator = None
for value in data:
if value is None:
unary_operator = None
yield self.Term()
continue
if not isinstance(value, str):
raise FieldsValueError(
"invalid value type, string expected",
field=self._field,
value=str(value),
)
if value == self._reset_operator:
unary_operator = None
yield self.Term(reset=True)
continue
if value.startswith(self.op_prefix):
if unary_operator:
raise FieldsValueError(
"Value is expected after",
field=self._field,
operator=unary_operator,
)
if value in self._unary_operators:
unary_operator = value
continue
operator = self._operators.get(value)
if operator is None:
raise FieldsValueError(
"Unsupported operator",
field=self._field,
operator=value,
)
yield self.Term(operator=operator)
continue
if (
not unary_operator
and self._support_legacy
and value.startswith("-")
):
value = value[1:]
if not value:
raise FieldsValueError(
"Missing value after the exclude prefix -",
field=self._field,
value=value,
)
yield self.Term(value=value, include=False)
continue
term = self.Term(value=value)
if unary_operator:
term.include = self._unary_operators[unary_operator]
unary_operator = None
yield term
if unary_operator:
raise FieldsValueError(
"Value is expected after", operator=unary_operator
)
get_all_query_options = QueryParameterOptions()
@@ -227,8 +280,8 @@ class GetMixin(PropsMixin):
cls._cache_manager = RedisCacheManager(
state_class=cls.GetManyScrollState,
redis=redman.connection("apiserver"),
expiration_interval=config.get(
"services._mongo.scroll_state_expiration_seconds", 600
expiration_interval=mongo_conf.get(
"scroll_state_expiration_seconds", 600
),
)
@@ -347,97 +400,121 @@ class GetMixin(PropsMixin):
parameters_options = parameters_options or cls.get_all_query_options
dict_query = {}
query = RegexQ()
if parameters:
parameters = {
k: cls._get_fixed_field_value(k, v) for k, v in parameters.items()
}
opts = parameters_options
for field in opts.pattern_fields:
pattern = parameters.pop(field, None)
if pattern:
dict_query[field] = RegexWrapper(pattern)
field = None
# noinspection PyBroadException
try:
if parameters:
parameters = {
k: cls._get_fixed_field_value(k, v) for k, v in parameters.items()
}
filters = parameters.pop("filters", {})
if not isinstance(filters, dict):
raise FieldsValueError(
"invalid value type, string expected",
field=filters,
value=str(filters),
)
opts = parameters_options
for field in opts.pattern_fields:
pattern = parameters.pop(field, None)
if pattern:
dict_query[field] = RegexWrapper(pattern)
for field, data in cls._pop_matching_params(
patterns=opts.list_fields, parameters=parameters
).items():
query &= cls.get_list_field_query(field, data)
for field, data in cls._pop_matching_params(
patterns=opts.list_fields, parameters=filters
).items():
query &= cls.get_list_filter_query(field, data)
parameters.pop(field, None)
for field, data in cls._pop_matching_params(
patterns=opts.range_fields, parameters=parameters
).items():
query &= cls.get_range_field_query(field, data)
for field, data in cls._pop_matching_params(
patterns=opts.list_fields, parameters=parameters
).items():
query &= cls.get_list_field_query(field, data)
for field, data in cls._pop_matching_params(
patterns=opts.fields or [], parameters=parameters
).items():
if "._" in field or "_." in field:
query &= RegexQ(__raw__={field: data})
else:
dict_query[field.replace(".", "__")] = data
for field, data in cls._pop_matching_params(
patterns=opts.range_fields, parameters=parameters
).items():
query &= cls.get_range_field_query(field, data)
for field in opts.datetime_fields or []:
data = parameters.pop(field, None)
if data is not None:
if not isinstance(data, list):
data = [data]
# date time fields also support simplified range queries. Check if this is the case
if len(data) == 2 and not any(
d.startswith(mod)
for d in data
if d is not None
for mod in ACCESS_MODIFIER
):
query &= cls.get_range_field_query(field, data)
for field, data in cls._pop_matching_params(
patterns=opts.fields or [], parameters=parameters
).items():
if "._" in field or "_." in field:
query &= RegexQ(__raw__={field: data})
else:
for d in data: # type: str
m = ACCESS_REGEX.match(d)
if not m:
continue
try:
value = parse_datetime(m.group("value"))
prefix = m.group("prefix")
modifier = ACCESS_MODIFIER.get(prefix)
f = (
field
if not modifier
else "__".join((field, modifier))
)
dict_query[f] = value
except (ValueError, OverflowError):
pass
dict_query[field.replace(".", "__")] = data
for field, value in parameters.items():
for keys, func in cls._multi_field_param_prefix.items():
if field not in keys:
continue
try:
data = cls.MultiFieldParameters(**value)
except Exception:
raise MakeGetAllQueryError("incorrect field format", field)
if not data.fields:
break
if any("._" in f for f in data.fields):
q = reduce(
lambda a, x: func(
a,
RegexQ(
__raw__={
x: {"$regex": data.pattern, "$options": "i"}
}
for field in opts.datetime_fields or []:
data = parameters.pop(field, None)
if data is not None:
if not isinstance(data, list):
data = [data]
# date time fields also support simplified range queries. Check if this is the case
if len(data) == 2 and not any(
d.startswith(mod)
for d in data
if d is not None
for mod in ACCESS_MODIFIER
):
query &= cls.get_range_field_query(field, data)
else:
for d in data: # type: str
m = ACCESS_REGEX.match(d)
if not m:
continue
try:
value = parse_datetime(m.group("value"))
prefix = m.group("prefix")
modifier = ACCESS_MODIFIER.get(prefix)
f = (
field
if not modifier
else "__".join((field, modifier))
)
dict_query[f] = value
except (ValueError, OverflowError):
pass
for field, value in parameters.items():
for keys, func in cls._multi_field_param_prefix.items():
if field not in keys:
continue
try:
data = cls.MultiFieldParameters(**value)
except Exception:
raise MakeGetAllQueryError("incorrect field format", field)
if not data.fields:
break
if any("._" in f for f in data.fields):
q = reduce(
lambda a, x: func(
a,
RegexQ(
__raw__={
x: {"$regex": data.pattern, "$options": "i"}
}
),
),
),
data.fields,
RegexQ(),
)
else:
regex = RegexWrapper(data.pattern, flags=re.IGNORECASE)
sep_fields = [f.replace(".", "__") for f in data.fields]
q = reduce(
lambda a, x: func(a, RegexQ(**{x: regex})),
sep_fields,
RegexQ(),
)
query = query & q
data.fields,
RegexQ(),
)
else:
regex = RegexWrapper(data.pattern, flags=re.IGNORECASE)
sep_fields = [f.replace(".", "__") for f in data.fields]
q = reduce(
lambda a, x: func(a, RegexQ(**{x: regex})),
sep_fields,
RegexQ(),
)
query = query & q
except APIError:
raise
except Exception as ex:
raise errors.bad_request.FieldsValueError(
"failed parsing query field",
error=str(ex),
**({"field": field} if field else {}),
)
return query & RegexQ(**dict_query)
@@ -472,6 +549,135 @@ class GetMixin(PropsMixin):
return q
@attr.s(auto_attribs=True)
class ListQueryFilter:
"""
Deserialize filters data and build db_query object that represents it with the corresponding
mongo engine operations
Each part has include and exclude lists that map to mongoengine operations as following:
"any"
- include -> 'in'
- exclude -> 'not_all'
- combined by 'or' operation
"all"
- include -> 'all'
- exclude -> 'nin'
- combined by 'and' operation
"op" optional parameter for combining "and" and "all" parts. Can be "and" or "or". The default is "and"
"""
_and_op = "and"
_or_op = "or"
_allowed_op = [_and_op, _or_op]
_db_modifiers: Mapping = {
(Q.OR, True): "in",
(Q.OR, False): "not__all",
(Q.AND, True): "all",
(Q.AND, False): "nin",
}
@attr.s(auto_attribs=True)
class ListFilter:
include: Sequence[str] = []
exclude: Sequence[str] = []
@classmethod
def from_dict(cls, d: Mapping):
if d is None:
return None
return cls(**d)
any: ListFilter = attr.ib(converter=ListFilter.from_dict, default=None)
all: ListFilter = attr.ib(converter=ListFilter.from_dict, default=None)
op: str = attr.ib(default="and")
db_query: dict = attr.ib(init=False)
# noinspection PyUnresolvedReferences
@op.validator
def op_validator(self, _, value):
if value not in self._allowed_op:
raise ValueError(
f"Invalid list query filter operator: {value}. "
f"Should be one of {str(self._allowed_op)}"
)
@property
def and_op(self) -> bool:
return self.op == self._and_op
def __attrs_post_init__(self):
self.db_query = {}
for op, conditions in ((Q.OR, self.any), (Q.AND, self.all)):
if not conditions:
continue
operations = {}
for vals, include in (
(conditions.include, True),
(conditions.exclude, False),
):
if not vals:
continue
operations[self._db_modifiers[(op, include)]] = list(set(vals))
self.db_query[op] = operations
@classmethod
def from_data(cls, field, data: Mapping):
if not isinstance(data, dict):
raise errors.bad_request.ValidationError(
"invalid filter for field, dictionary expected",
field=field,
value=str(data),
)
try:
return cls(**data)
except Exception as ex:
raise errors.bad_request.ValidationError(
field=field,
value=str(ex),
)
@classmethod
def get_list_filter_query(
cls, field: str, data: Mapping
) -> Union[RegexQ, RegexQCombination]:
if not data:
return RegexQ()
filter_ = cls.ListQueryFilter.from_data(field, data)
mongoengine_field = field.replace(".", "__")
queries = []
for op, actions in filter_.db_query.items():
if not actions:
continue
ops = []
for action, vals in actions.items():
if not vals:
continue
ops.append(RegexQ(**{f"{mongoengine_field}__{action}": vals}))
if not ops:
continue
if len(ops) == 1:
queries.extend(ops)
continue
queries.append(RegexQCombination(operation=op, children=ops))
if not queries:
return RegexQ()
if len(queries) == 1:
return queries[0]
operation = Q.AND if filter_.and_op else Q.OR
return RegexQCombination(operation=operation, children=queries)
@classmethod
def get_list_field_query(cls, field: str, data: Sequence[Optional[str]]) -> RegexQ:
"""
@@ -486,15 +692,15 @@ class GetMixin(PropsMixin):
if not isinstance(data, (list, tuple)):
data = [data]
helper = cls.ListFieldBucketHelper(legacy=True)
global_op = helper.get_global_op(data)
actions = helper.get_actions(data)
helper = cls.NewListFieldBucketHelper(field, data=data, legacy=True)
global_op = helper.global_operator
actions = helper.actions
mongoengine_field = field.replace(".", "__")
queries = [
RegexQ(**{f"{mongoengine_field}__{action}": list(set(actions[action]))})
for action in filter(None, actions)
RegexQ(**{f"{mongoengine_field}__{action}": list(set(values))})
for action, values in actions.items()
]
if not queries:
@@ -555,7 +761,7 @@ class GetMixin(PropsMixin):
if start is not None:
return start, cls.validate_scroll_size(parameters)
max_page_size = config.get("services._mongo.max_page_size", 500)
max_page_size = mongo_conf.get("max_page_size", 500)
page = parameters.get("page", default_page)
if page is not None and page < 0:
raise errors.bad_request.ValidationError("page must be >=0", field="page")
@@ -580,7 +786,7 @@ class GetMixin(PropsMixin):
@classmethod
def get_projection(cls, parameters, override_projection=None, **__):
""" Extract a projection list from the provided dictionary. Supports an override projection. """
"""Extract a projection list from the provided dictionary. Supports an override projection."""
if override_projection is not None:
return override_projection
if not parameters:
@@ -594,7 +800,8 @@ class GetMixin(PropsMixin):
"""Return include and exclude lists based on passed projection and class definition"""
if projection:
include, exclude = partition(
projection, key=lambda x: x[0] != ProjectionHelper.exclusion_prefix,
projection,
key=lambda x: x[0] != ProjectionHelper.exclusion_prefix,
)
else:
include, exclude = [], []
@@ -733,7 +940,9 @@ class GetMixin(PropsMixin):
@classmethod
def _get_collation_override(cls, field: str) -> Optional[dict]:
return first(
v for k, v in cls._field_collation_overrides.items() if field.startswith(k)
v
for k, v in cls._field_collation_overrides.items()
if field.startswith(k) or field.startswith(f"-{k}")
)
@classmethod
@@ -839,7 +1048,9 @@ class GetMixin(PropsMixin):
projection_fields=projection_fields,
)
return cls.get_data_with_scroll_support(
query_dict=query_dict, data_getter=data_getter, ret_params=ret_params,
query_dict=query_dict,
data_getter=data_getter,
ret_params=ret_params,
)
return cls._get_many_no_company(
@@ -852,7 +1063,9 @@ class GetMixin(PropsMixin):
@classmethod
def get_many_public(
cls, query: Q = None, projection: Collection[str] = None,
cls,
query: Q = None,
projection: Collection[str] = None,
):
"""
Fetch all public documents matching a provided query.
@@ -865,6 +1078,13 @@ class GetMixin(PropsMixin):
return cls._get_many_no_company(query=_query, override_projection=projection)
@staticmethod
def _get_qs_with_ordering(qs: QuerySet, order_by: Sequence):
disk_use_setting = mongo_conf.get("allow_disk_use.sort", None)
if disk_use_setting is not None:
qs = qs.allow_disk_use(disk_use_setting)
return qs.order_by(*order_by)
@classmethod
def _get_many_no_company(
cls: Union["GetMixin", Document],
@@ -906,7 +1126,7 @@ class GetMixin(PropsMixin):
qs = qs.search_text(search_text)
if order_by:
# add ordering
qs = qs.order_by(*order_by)
qs = cls._get_qs_with_ordering(qs, order_by)
if include:
# add projection
@@ -998,7 +1218,7 @@ class GetMixin(PropsMixin):
res = cls._get_queries_for_order_field(query, order_field)
if res:
query_sets = [cls.objects(q) for q in res]
query_sets = [qs.order_by(*order_by) for qs in query_sets]
query_sets = [cls._get_qs_with_ordering(qs, order_by) for qs in query_sets]
if order_field and not override_collation:
override_collation = cls._get_collation_override(order_field)
@@ -1138,7 +1358,7 @@ class UpdateMixin(object):
class DbModelMixin(GetMixin, ProperDictMixin, UpdateMixin):
""" Provide convenience methods for a subclass of mongoengine.Document """
"""Provide convenience methods for a subclass of mongoengine.Document"""
@classmethod
def aggregate(
@@ -1158,7 +1378,7 @@ class DbModelMixin(GetMixin, ProperDictMixin, UpdateMixin):
kwargs.update(
allowDiskUse=allow_disk_use
if allow_disk_use is not None
else config.get("apiserver.mongo.aggregate.allow_disk_use", True)
else mongo_conf.get("allow_disk_use.aggregate", True)
)
return cls.objects.aggregate(pipeline, **kwargs)
@@ -1166,25 +1386,31 @@ class DbModelMixin(GetMixin, ProperDictMixin, UpdateMixin):
def set_public(
cls: Type[Document],
company_id: str,
user_id: str,
ids: Sequence[str],
invalid_cls: Type[BaseError],
enabled: bool = True,
):
if enabled:
items = list(cls.objects(id__in=ids, company=company_id).only("id"))
update = dict(set__company_origin=company_id, set__company="")
update: dict = dict(set__company_origin=company_id, set__company="")
else:
items = list(
cls.objects(
id__in=ids, company__in=(None, ""), company_origin=company_id
).only("id")
)
update = dict(set__company=company_id, unset__company_origin=1)
update: dict = dict(set__company=company_id, unset__company_origin=1)
if len(items) < len(ids):
missing = tuple(set(ids).difference(i.id for i in items))
raise invalid_cls(ids=missing)
if hasattr(cls, "last_change"):
update["set__last_change"] = datetime.utcnow()
if hasattr(cls, "last_changed_by"):
update["set__last_changed_by"] = user_id
return {"updated": cls.objects(id__in=ids).update(**update)}

View File

@@ -3,6 +3,8 @@ from mongoengine import (
DateTimeField,
BooleanField,
EmbeddedDocumentField,
IntField,
ListField,
)
from apiserver.database import Database, strict
@@ -17,12 +19,14 @@ from apiserver.database.model.base import GetMixin
from apiserver.database.model.metadata import MetadataItem
from apiserver.database.model.model_labels import ModelLabels
from apiserver.database.model.project import Project
from apiserver.database.model.task.metrics import MetricEvent
from apiserver.database.model.task.task import Task
class Model(AttributedDocument):
_field_collation_overrides = {
"metadata.": AttributedDocument._numeric_locale,
"last_metrics.": AttributedDocument._numeric_locale,
}
meta = {
@@ -67,6 +71,7 @@ class Model(AttributedDocument):
"parent",
"metadata.*",
),
range_fields=("last_metrics.*", "last_iteration"),
datetime_fields=("last_update",),
)
@@ -85,6 +90,8 @@ class Model(AttributedDocument):
labels = ModelLabels()
ready = BooleanField(required=True)
last_update = DateTimeField()
last_change = DateTimeField()
last_changed_by = StringField()
ui_cache = SafeDictField(
default=dict, user_set_allowed=True, exclude_by_default=True
)
@@ -92,6 +99,9 @@ class Model(AttributedDocument):
metadata = SafeMapField(
field=EmbeddedDocumentField(MetadataItem), user_set_allowed=True
)
last_iteration = IntField(default=0)
last_metrics = SafeMapField(field=SafeMapField(EmbeddedDocumentField(MetricEvent)))
unique_metrics = ListField(StringField(required=True), exclude_by_default=True)
def get_index_company(self) -> str:
return self.company or self.company_origin or ""

View File

@@ -4,6 +4,7 @@ from mongoengine import (
DynamicField,
LongField,
EmbeddedDocumentField,
IntField,
)
from apiserver.database.fields import SafeMapField
@@ -19,7 +20,9 @@ class MetricEvent(EmbeddedDocument):
variant = StringField(required=True)
value = DynamicField(required=True)
min_value = DynamicField() # for backwards compatibility reasons
min_value_iteration = IntField()
max_value = DynamicField() # for backwards compatibility reasons
max_value_iteration = IntField()
class EventStats(EmbeddedDocument):

View File

@@ -19,6 +19,7 @@ from apiserver.database.fields import (
SafeSortedListField,
EmbeddedDocumentListField,
NullableStringField,
NoneType,
)
from apiserver.database.model import AttributedDocument
from apiserver.database.model.base import ProperDictMixin, GetMixin
@@ -89,7 +90,9 @@ class Artifact(EmbeddedDocument):
content_size = LongField()
timestamp = LongField()
type_data = EmbeddedDocumentField(ArtifactTypeData)
display_data = SafeSortedListField(ListField(UnionField((int, float, str))))
display_data = SafeSortedListField(
ListField(UnionField((int, float, str, NoneType)))
)
class ParamsItem(EmbeddedDocument, ProperDictMixin):
@@ -149,6 +152,7 @@ class TaskType(object):
application = "application"
monitor = "monitor"
controller = "controller"
report = "report"
optimizer = "optimizer"
service = "service"
qc = "qc"
@@ -195,6 +199,7 @@ class Task(AttributedDocument):
"$name",
"$id",
"$comment",
"$report",
"$models.input.model",
"$models.output.model",
"$script.repository",
@@ -205,6 +210,7 @@ class Task(AttributedDocument):
"name": 10,
"id": 10,
"comment": 10,
"report": 10,
"models.output.model": 2,
"models.input.model": 2,
"script.repository": 1,
@@ -224,10 +230,12 @@ class Task(AttributedDocument):
"project",
"parent",
"hyperparams.*",
"execution.queue",
),
range_fields=("started", "active_duration", "last_metrics.*", "last_iteration"),
datetime_fields=("status_changed", "last_update"),
pattern_fields=("name", "comment"),
pattern_fields=("name", "comment", "report"),
fields=("runtime.*", "models.input.model"),
)
id = StringField(primary_key=True)
@@ -241,6 +249,8 @@ class Task(AttributedDocument):
status_message = StringField(user_set_allowed=True)
status_changed = DateTimeField()
comment = StringField(user_set_allowed=True)
report = StringField()
report_assets = ListField(StringField())
created = DateTimeField(required=True, user_set_allowed=True)
started = DateTimeField()
completed = DateTimeField()
@@ -262,7 +272,7 @@ class Task(AttributedDocument):
unique_metrics = ListField(StringField(required=True), exclude_by_default=True)
metric_stats = SafeMapField(field=EmbeddedDocumentField(MetricEventStats))
company_origin = StringField(exclude_by_default=True)
duration = IntField() # task duration in seconds
duration = IntField() # obsolete, do not use
hyperparams = SafeMapField(field=SafeMapField(EmbeddedDocumentField(ParamsItem)))
configuration = SafeMapField(field=EmbeddedDocumentField(ConfigurationItem))
runtime = SafeDictField(default=dict)
@@ -271,6 +281,7 @@ class Task(AttributedDocument):
enqueue_status = StringField(
choices=get_options(TaskStatus), exclude_by_default=True
)
last_changed_by = StringField()
def get_index_company(self) -> str:
"""

View File

@@ -8,6 +8,9 @@ from apiserver.database.model import AttributedDocument
class StorageType(str, Enum):
fileserver = "fileserver"
s3 = "s3"
azure = "azure"
gs = "gs"
unknown = "unknown"
@@ -32,10 +35,8 @@ class UrlToDelete(AttributedDocument):
"strict": strict,
"indexes": [
("company", "user", "task"),
"storage_type",
"created",
"retry_count",
"type",
("company", "storage_type", "url"),
("status", "retry_count", "storage_type"),
],
}

View File

@@ -3,10 +3,14 @@ from concurrent.futures import ThreadPoolExecutor
from itertools import groupby, chain
from typing import Sequence, Dict, Callable
from boltons import iterutils
from apiserver.apierrors import errors
from apiserver.config_repo import config
from apiserver.database.props import PropsMixin
SEP = "."
max_items_per_fetch = config.get("services._mongo.max_page_size", 500)
class _ReferenceProxy(dict):
@@ -278,10 +282,11 @@ class ProjectionHelper(object):
doc_only = list(filter(None, data["only"]))
doc_only = list({"id"} | set(doc_only)) if doc_only else None
for res in projection_func(
doc_type=doc_type, projection=doc_only, ids=ids
):
self._proxy_manager.update(res)
for ids_chunk in iterutils.chunked_iter(ids, max_items_per_fetch):
for res in projection_func(
doc_type=doc_type, projection=doc_only, ids=ids_chunk
):
self._proxy_manager.update(res)
if len(ref_projection) == 1:
do_projection(items[0])

View File

@@ -0,0 +1,19 @@
### Supported api versions
| Release | ApiVersion |
|---------|------------|
| v1.13 | 2.27 |
| v1.12 | 2.26 |
| v1.11 | 2.25 |
| v1.10 | 2.24 |
| v1.9 | 2.23 |
| v1.8 | 2.22 |
| v1.7 | 2.21 |
| v1.6 | 2.20 |
| v1.5 | 2.19 |
| v1.4 | 2.18 |
| v1.3 | 2.17 |
| v1.2 | 2.16 |
| v1.1 | 2.15 |
| v1.0 | 2.14 |
| v0.17 | 2.13 |

View File

@@ -21,7 +21,7 @@ def apply_mappings_to_cluster(
with f.open() as json_data:
data = json.load(json_data)
template_name = f.stem
res = es.indices.put_template(template_name, body=data)
res = es.indices.put_template(name=template_name, body=data)
return {"mapping": template_name, "result": res}
p = HERE / "mappings"

View File

@@ -85,7 +85,7 @@ def check_elastic_empty() -> bool:
es = Elasticsearch(
hosts=cluster_conf.get("hosts", None),
http_auth=es_factory.get_credentials("events", cluster_conf),
**cluster_conf.get("args", {})
**cluster_conf.get("args", {}),
)
return not es.indices.get_template(name="events*")
except exceptions.NotFoundError as ex:
@@ -115,5 +115,7 @@ def init_es_data():
args = cluster_conf.get("args", {})
http_auth = es_factory.get_credentials(name)
res = apply_mappings_to_cluster(hosts_config, name, es_args=args, http_auth=http_auth)
res = apply_mappings_to_cluster(
hosts_config, name, es_args=args, http_auth=http_auth
)
log.info(res)

View File

@@ -1,8 +1,8 @@
{
"index_patterns": "events-*",
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_replicas": 0,
"number_of_shards": 1
},
"mappings": {
"_source": {

View File

@@ -1,8 +1,8 @@
{
"index_patterns": "queue_metrics_*",
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_replicas": 0,
"number_of_shards": 1
},
"mappings": {
"_source": {
@@ -20,6 +20,9 @@
},
"queue_length": {
"type": "integer"
},
"company_id": {
"type": "keyword"
}
}
}

View File

@@ -1,8 +1,8 @@
{
"index_patterns": "worker_stats_*",
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_replicas": 0,
"number_of_shards": 1
},
"mappings": {
"_source": {
@@ -32,6 +32,9 @@
},
"task": {
"type": "keyword"
},
"company_id": {
"type": "keyword"
}
}
}

View File

@@ -67,7 +67,7 @@ class MissingPasswordForElasticUser(Exception):
class ESFactory:
@classmethod
def connect(cls, cluster_name):
def connect(cls, cluster_name) -> Elasticsearch:
"""
Returns the es client for the cluster.
Connects to the cluster if did not connect previously

View File

@@ -1,3 +1,5 @@
import os
from abc import ABC, ABCMeta, abstractmethod
from argparse import ArgumentParser
from collections import defaultdict
from datetime import datetime, timedelta
@@ -5,38 +7,28 @@ from functools import partial
from itertools import chain
from pathlib import Path
from time import sleep
from typing import Sequence, Tuple
from typing import Sequence, Optional, Tuple, Mapping, TypeVar, Hashable, Generic
from urllib.parse import urlparse
import boto3
import requests
from azure.storage.blob import ContainerClient, PartialBatchErrorException
from boltons.iterutils import bucketize, chunked_iter
from furl import furl
from google.cloud import storage as google_storage
from mongoengine import Q
from mypy_boto3_s3.service_resource import Bucket as AWSBucket
from apiserver.bll.storage import StorageBLL
from apiserver.config_repo import config
from apiserver.database import db
from apiserver.database.model.url_to_delete import (
UrlToDelete,
DeletionStatus,
StorageType,
)
from apiserver.database.model.url_to_delete import UrlToDelete, StorageType, DeletionStatus
log = config.logger(f"JOB-{Path(__file__).name}")
conf = config.get("services.async_urls_delete")
max_retries = conf.get("max_retries", 3)
retry_timeout = timedelta(seconds=conf.get("retry_timeout_sec", 60))
fileserver_timeout = conf.get("fileserver.timeout_sec", 300)
UrlPrefix = Tuple[str, str]
def validate_fileserver_access(fileserver_host: str) -> str:
fileserver_host = fileserver_host or config.get("hosts.fileserver", None)
if not fileserver_host:
log.error(f"Fileserver host not configured")
exit(1)
res = requests.get(url=fileserver_host)
res.raise_for_status()
return fileserver_host
storage_bll = StorageBLL()
def mark_retry_failed(ids: Sequence[str], reason: str):
@@ -58,112 +50,517 @@ def mark_failed(query: Q, reason: str):
)
def delete_fileserver_urls(
urls_query: Q, fileserver_host: str, url_prefixes: Sequence[UrlPrefix]
):
to_delete = list(UrlToDelete.objects(urls_query).limit(10000))
def scheme_prefix(scheme: str) -> str:
return str(furl(scheme=scheme, netloc=""))
T = TypeVar("T", bound=Hashable)
class Storage(Generic[T], metaclass=ABCMeta):
class Client(ABC):
@property
@abstractmethod
def chunk_size(self) -> int:
pass
def get_path(self, url: UrlToDelete) -> str:
pass
def delete_many(
self, paths: Sequence[str]
) -> Tuple[Sequence[str], Mapping[str, Sequence[str]]]:
pass
@property
@abstractmethod
def name(self) -> str:
pass
def group_urls(
self, urls: Sequence[UrlToDelete]
) -> Mapping[T, Sequence[UrlToDelete]]:
pass
def get_client(self, base: T, urls: Sequence[UrlToDelete]) -> Client:
pass
def delete_urls(urls_query: Q, storage: Storage):
to_delete = list(UrlToDelete.objects(urls_query).order_by("url").limit(10000))
if not to_delete:
return
def resolve_path(url_: UrlToDelete) -> str:
parsed = furl(url_.url)
url_host = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme else None
url_path = str(parsed.path)
grouped_urls = storage.group_urls(to_delete)
for base, urls in grouped_urls.items():
if not base:
msg = f"Invalid {storage.name} url or missing {storage.name} configuration for account"
mark_failed(
Q(id__in=[url.id for url in urls]), msg,
)
log.warning(
f"Failed to delete {len(urls)} files from {storage.name} due to: {msg}"
)
continue
for host, path_prefix in url_prefixes:
try:
client = storage.get_client(base, urls)
except Exception as ex:
failed = [url.id for url in urls]
mark_retry_failed(failed, reason=str(ex))
log.warning(
f"Failed to delete {len(failed)} files from {storage.name} due to: {str(ex)}"
)
continue
for chunk in chunked_iter(urls, client.chunk_size):
paths = []
path_to_id_mapping = defaultdict(list)
ids_to_delete = set()
for url in chunk:
try:
path = client.get_path(url)
except Exception as ex:
err = str(ex)
mark_failed(Q(id=url.id), err)
log.warning(f"Error getting path for {url.url}: {err}")
continue
paths.append(path)
path_to_id_mapping[path].append(url.id)
ids_to_delete.add(url.id)
if not paths:
continue
try:
deleted_paths, errors = client.delete_many(paths)
except Exception as ex:
mark_retry_failed([url.id for url in urls], str(ex))
log.warning(
f"Error deleting {len(paths)} files from {storage.name}: {str(ex)}"
)
continue
failed_ids = set()
for reason, err_paths in errors.items():
error_ids = set(
chain.from_iterable(
path_to_id_mapping.get(p, []) for p in err_paths
)
)
mark_retry_failed(list(error_ids), reason)
log.warning(
f"Failed to delete {len(error_ids)} files from {storage.name} storage due to: {reason}"
)
failed_ids.update(error_ids)
deleted_ids = set(
chain.from_iterable(
path_to_id_mapping.get(p, []) for p in deleted_paths
)
)
if deleted_ids:
UrlToDelete.objects(id__in=list(deleted_ids)).delete()
log.info(
f"{len(deleted_ids)} files deleted from {storage.name} storage"
)
missing_ids = ids_to_delete - deleted_ids - failed_ids
if missing_ids:
mark_retry_failed(list(missing_ids), "Not succeeded")
class FileserverStorage(Storage):
class Client(Storage.Client):
timeout = conf.get("fileserver.timeout_sec", 300)
def __init__(self, session: requests.Session, host: str):
self.session = session
self.delete_url = furl(host).add(path="delete_many").url
@property
def chunk_size(self) -> int:
return 10000
def get_path(self, url: UrlToDelete) -> str:
path = url.url.strip("/")
if not path:
raise ValueError("Empty path")
return path
def delete_many(
self, paths: Sequence[str]
) -> Tuple[Sequence[str], Mapping[str, Sequence[str]]]:
res = self.session.post(
url=self.delete_url, json={"files": list(paths)}, timeout=self.timeout
)
res.raise_for_status()
res_data = res.json()
return list(res_data.get("deleted", {})), res_data.get("errors", {})
def __init__(self, company: str, fileserver_host: str = None):
fileserver_host = fileserver_host or config.get("hosts.fileserver", None)
self.host = fileserver_host.rstrip("/")
if not self.host:
log.warning(f"Fileserver host not configured")
def _parse_url_prefix(prefix) -> Tuple[str, str]:
url = furl(prefix)
host = f"{url.scheme}://{url.netloc}" if url.scheme else None
return host, str(url.path).rstrip("/")
url_prefixes = [
_parse_url_prefix(p) for p in conf.get("fileserver.url_prefixes", [])
]
if not any(self.host == host for host, _ in url_prefixes):
url_prefixes.append((self.host, ""))
self.url_prefixes = url_prefixes
self.company = company
# @classmethod
# def validate_fileserver_access(cls, fileserver_host: str):
# res = requests.get(
# url=fileserver_host
# )
# res.raise_for_status()
@property
def name(self) -> str:
return "Fileserver"
def _resolve_base_url(self, url: UrlToDelete) -> Optional[str]:
"""
For the url return the base_url containing schema, optional host and bucket name
"""
if not url.url:
return None
try:
parsed = furl(url.url)
url_host = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme else None
url_path = str(parsed.path)
except Exception:
return None
for host, path_prefix in self.url_prefixes:
if host and url_host != host:
continue
if path_prefix and not url_path.startswith(path_prefix + "/"):
continue
return url_path[len(path_prefix or ""):]
url.url = url_path[len(path_prefix or "") :]
return self.host
raise ValueError("could not map path")
def group_urls(
self, urls: Sequence[UrlToDelete]
) -> Mapping[str, Sequence[UrlToDelete]]:
return bucketize(urls, key=self._resolve_base_url)
paths = set()
path_to_id_mapping = defaultdict(list)
for url in to_delete:
try:
path = resolve_path(url)
path = path.strip("/")
if not path:
raise ValueError("Empty path")
except Exception as ex:
err = str(ex)
log.warn(f"Error getting path for {url.url}: {err}")
mark_failed(Q(id=url.id), err)
continue
paths.add(path)
path_to_id_mapping[path].append(url.id)
if not paths:
return
ids_to_delete = set(chain.from_iterable(path_to_id_mapping.values()))
try:
res = requests.post(
url=furl(fileserver_host).add(path="delete_many").url,
json={"files": list(paths)},
timeout=fileserver_timeout,
)
def get_client(self, base: str, urls: Sequence[UrlToDelete]) -> Client:
host = base
session = requests.session()
res = session.get(url=host, timeout=self.Client.timeout)
res.raise_for_status()
except Exception as ex:
err = str(ex)
log.warn(f"Error deleting {len(paths)} files from fileserver: {err}")
mark_retry_failed(list(ids_to_delete), err)
return
res_data = res.json()
deleted_ids = set(
chain.from_iterable(
path_to_id_mapping.get(path, [])
for path in list(res_data.get("deleted", {}))
return self.Client(session, host)
class AzureStorage(Storage):
class Client(Storage.Client):
def __init__(self, container: ContainerClient):
self.container = container
@property
def chunk_size(self) -> int:
return 256
def get_path(self, url: UrlToDelete) -> str:
parsed = furl(url.url)
if (
not parsed.path
or not parsed.path.segments
or len(parsed.path.segments) <= 1
):
raise ValueError("No path found following container name")
return os.path.join(*parsed.path.segments[1:])
@staticmethod
def _path_from_request_url(request_url: str) -> str:
try:
return furl(request_url).path.segments[-1]
except Exception:
return request_url
def delete_many(
self, paths: Sequence[str]
) -> Tuple[Sequence[str], Mapping[str, Sequence[str]]]:
try:
res = self.container.delete_blobs(*paths)
except PartialBatchErrorException as pex:
deleted = []
errors = defaultdict(list)
for part in pex.parts:
if 300 >= part.status_code >= 200:
deleted.append(self._path_from_request_url(part.request.url))
else:
errors[part.reason].append(
self._path_from_request_url(part.request.url)
)
return deleted, errors
return [self._path_from_request_url(part.request.url) for part in res], {}
def __init__(self, company: str):
self.configs = storage_bll.get_azure_settings_for_company(company)
self.scheme = "azure"
@property
def name(self) -> str:
return "Azure"
def _resolve_base_url(self, url: UrlToDelete) -> Optional[Tuple]:
"""
For the url return the base_url containing schema, optional host and bucket name
"""
try:
parsed = urlparse(url.url)
if parsed.scheme != self.scheme:
return None
azure_conf = self.configs.get_config_by_uri(url.url)
if azure_conf is None:
return None
account_url = parsed.netloc
return account_url, azure_conf.container_name
except Exception as ex:
log.warning(f"Error resolving base url for {url.url}: " + str(ex))
return None
def group_urls(
self, urls: Sequence[UrlToDelete]
) -> Mapping[Tuple, Sequence[UrlToDelete]]:
return bucketize(urls, key=self._resolve_base_url)
def get_client(self, base: Tuple, urls: Sequence[UrlToDelete]) -> Client:
account_url, container_name = base
sample_url = urls[0].url
cfg = self.configs.get_config_by_uri(sample_url)
if not cfg or not cfg.account_name or not cfg.account_key:
raise ValueError(
f"Missing account name or key for Azure Blob Storage "
f"account: {account_url}, container: {container_name}"
)
return self.Client(
ContainerClient(
account_url=account_url,
container_name=cfg.container_name,
credential={
"account_name": cfg.account_name,
"account_key": cfg.account_key,
},
)
)
)
if deleted_ids:
UrlToDelete.objects(id__in=list(deleted_ids)).delete()
log.info(f"{len(deleted_ids)} files deleted from the fileserver")
failed_ids = set()
for err, error_ids in res_data.get("errors", {}).items():
error_ids = list(
chain.from_iterable(path_to_id_mapping.get(path, []) for path in error_ids)
class AWSStorage(Storage):
class Client(Storage.Client):
def __init__(self, base_url: str, container: AWSBucket):
self.container = container
self.base_url = base_url
@property
def chunk_size(self) -> int:
return 1000
def get_path(self, url: UrlToDelete) -> str:
""" Normalize remote path. Remove any prefix that is already handled by the container """
path = url.url
if path.startswith(self.base_url):
path = path[len(self.base_url) :]
path = path.lstrip("/")
return path
@staticmethod
def _path_from_request_url(request_url: str) -> str:
try:
return furl(request_url).path.segments[-1]
except Exception:
return request_url
def delete_many(
self, paths: Sequence[str]
) -> Tuple[Sequence[str], Mapping[str, Sequence[str]]]:
res = self.container.delete_objects(
Delete={"Objects": [{"Key": p} for p in paths]}
)
errors = defaultdict(list)
for err in res.get("Errors", []):
msg = err.get("Message", "")
errors[msg].append(err.get("Key"))
return [d.get("Key") for d in res.get("Deleted", [])], errors
def __init__(self, company: str):
self.configs = storage_bll.get_aws_settings_for_company(company)
self.scheme = "s3"
@property
def name(self) -> str:
return "AWS"
def _resolve_base_url(self, url: UrlToDelete) -> Optional[str]:
"""
For the url return the base_url containing schema, optional host and bucket name
"""
try:
parsed = urlparse(url.url)
if parsed.scheme != self.scheme:
return None
s3_conf = self.configs.get_config_by_uri(url.url)
if s3_conf is None:
return None
s3_bucket = s3_conf.bucket
if not s3_bucket:
parts = Path(parsed.path.strip("/")).parts
if parts:
s3_bucket = parts[0]
return "/".join(filter(None, ("s3:/", s3_conf.host, s3_bucket)))
except Exception as ex:
log.warning(f"Error resolving base url for {url.url}: " + str(ex))
return None
def group_urls(
self, urls: Sequence[UrlToDelete]
) -> Mapping[str, Sequence[UrlToDelete]]:
return bucketize(urls, key=self._resolve_base_url)
def get_client(self, base: str, urls: Sequence[UrlToDelete]) -> Client:
sample_url = urls[0].url
cfg = self.configs.get_config_by_uri(sample_url)
boto_kwargs = {
"endpoint_url": (("https://" if cfg.secure else "http://") + cfg.host)
if cfg.host
else None,
"use_ssl": cfg.secure,
"verify": cfg.verify,
}
name = base[len(scheme_prefix(self.scheme)) :]
bucket_name = name[len(cfg.host) + 1 :] if cfg.host else name
if not cfg.use_credentials_chain:
if not cfg.key or not cfg.secret:
raise ValueError(
f"Missing key or secret for AWS S3 host: {cfg.host}, bucket: {str(bucket_name)}"
)
boto_kwargs["aws_access_key_id"] = cfg.key
boto_kwargs["aws_secret_access_key"] = cfg.secret
if cfg.token:
boto_kwargs["aws_session_token"] = cfg.token
return self.Client(
base, boto3.resource("s3", **boto_kwargs).Bucket(bucket_name)
)
mark_retry_failed(error_ids, err)
log.warning(
f"Failed to delete {len(error_ids)} files from the fileserver due to: {err}"
class GoogleCloudStorage(Storage):
class Client(Storage.Client):
def __init__(self, base_url: str, container: google_storage.Bucket):
self.container = container
self.base_url = base_url
@property
def chunk_size(self) -> int:
return 100
def get_path(self, url: UrlToDelete) -> str:
""" Normalize remote path. Remove any prefix that is already handled by the container """
path = url.url
if path.startswith(self.base_url):
path = path[len(self.base_url) :]
path = path.lstrip("/")
return path
def delete_many(
self, paths: Sequence[str]
) -> Tuple[Sequence[str], Mapping[str, Sequence[str]]]:
not_found = set()
def error_callback(blob: google_storage.Blob):
not_found.add(blob.name)
self.container.delete_blobs(
[self.container.blob(p) for p in paths], on_error=error_callback,
)
errors = {"Not found": list(not_found)} if not_found else {}
return list(set(paths) - not_found), errors
def __init__(self, company: str):
self.configs = storage_bll.get_gs_settings_for_company(company)
self.scheme = "gs"
@property
def name(self) -> str:
return "Google Storage"
def _resolve_base_url(self, url: UrlToDelete) -> Optional[str]:
"""
For the url return the base_url containing schema, optional host and bucket name
"""
try:
parsed = urlparse(url.url)
if parsed.scheme != self.scheme:
return None
gs_conf = self.configs.get_config_by_uri(url.url)
if gs_conf is None:
return None
return str(furl(scheme=parsed.scheme, netloc=gs_conf.bucket))
except Exception as ex:
log.warning(f"Error resolving base url for {url.url}: " + str(ex))
return None
def group_urls(
self, urls: Sequence[UrlToDelete]
) -> Mapping[str, Sequence[UrlToDelete]]:
return bucketize(urls, key=self._resolve_base_url)
def get_client(self, base: str, urls: Sequence[UrlToDelete]) -> Client:
sample_url = urls[0].url
cfg = self.configs.get_config_by_uri(sample_url)
if cfg.credentials_json:
from google.oauth2 import service_account
credentials = service_account.Credentials.from_service_account_file(
cfg.credentials_json
)
else:
credentials = None
bucket_name = base[len(scheme_prefix(self.scheme)) :]
return self.Client(
base,
google_storage.Client(project=cfg.project, credentials=credentials).bucket(
bucket_name
),
)
failed_ids.update(error_ids)
missing_ids = ids_to_delete - deleted_ids - failed_ids
if missing_ids:
mark_retry_failed(list(missing_ids), "Not succeeded")
def _get_fileserver_url_prefixes(fileserver_host: str) -> Sequence[UrlPrefix]:
def _parse_url_prefix(prefix) -> UrlPrefix:
url = furl(prefix)
host = f"{url.scheme}://{url.netloc}" if url.scheme else None
return host, str(url.path).rstrip("/")
url_prefixes = [
_parse_url_prefix(p) for p in conf.get("fileserver.url_prefixes", [])
]
if not any(fileserver_host == host for host, _ in url_prefixes):
url_prefixes.append((fileserver_host, ""))
return url_prefixes
def run_delete_loop(fileserver_host: str):
fileserver_host = validate_fileserver_access(fileserver_host)
storage_delete_funcs = {
storage_helpers = {
StorageType.fileserver: partial(
delete_fileserver_urls,
fileserver_host=fileserver_host,
url_prefixes=_get_fileserver_url_prefixes(fileserver_host),
FileserverStorage, fileserver_host=fileserver_host
),
StorageType.s3: AWSStorage,
StorageType.azure: AzureStorage,
StorageType.gs: GoogleCloudStorage,
}
while True:
now = datetime.utcnow()
@@ -177,7 +574,7 @@ def run_delete_loop(fileserver_host: str):
)
url_to_delete: UrlToDelete = UrlToDelete.objects(
urls_query & Q(storage_type__in=list(storage_delete_funcs))
urls_query & Q(storage_type__in=list(storage_helpers))
).order_by("retry_count").limit(1).first()
if not url_to_delete:
sleep(10)
@@ -192,7 +589,10 @@ def run_delete_loop(fileserver_host: str):
company_storage_urls_query = urls_query & Q(
company=company, storage_type=storage_type,
)
storage_delete_funcs[storage_type](urls_query=company_storage_urls_query)
delete_urls(
urls_query=company_storage_urls_query,
storage=storage_helpers[storage_type](company=company),
)
def main():

View File

@@ -18,9 +18,15 @@ _migration_dir = _parent_dir / _migrations
def check_mongo_empty() -> bool:
return not all(
get_db(alias).collection_names() for alias in utils.get_options(Database)
)
for alias in utils.get_options(Database):
collection_names = get_db(alias).list_collection_names()
if collection_names and any(
name in collection_names
for name in ["company", "user", "versions"]
):
return False
return True
def get_last_server_version() -> Version:

View File

@@ -960,13 +960,17 @@ class PrePopulate:
return tasks
@classmethod
def _import_events(cls, f: IO[bytes], company_id: str, _, task_id: str):
def _import_events(cls, f: IO[bytes], company_id: str, user_id: str, task_id: str):
print(f"Writing events for task {task_id} into database")
for events_chunk in chunked_iter(cls.json_lines(f), 1000):
events = [json.loads(item) for item in events_chunk]
for ev in events:
ev["task"] = task_id
ev["company_id"] = company_id
ev["allow_locked"] = True
cls.event_bll.add_events(
company_id, events=events, worker="", allow_locked=True
company_id=company_id,
user_id=user_id,
events=events,
worker="",
)

View File

@@ -0,0 +1,17 @@
import logging as log
from pymongo.collection import Collection
from pymongo.database import Database
from pymongo.errors import OperationFailure
def migrate_backend(db: Database):
"""
Drop task text index so that the new one including reports field is created
"""
tasks: Collection = db["task"]
try:
tasks.drop_index("backend-db.task.main_text_index")
except OperationFailure as ex:
log.warning(f"Could not delete task text index due to: {str(ex)}")
pass

View File

@@ -2,7 +2,7 @@ from os import getenv
from boltons.iterutils import first
from redis import StrictRedis
from rediscluster import RedisCluster
from redis.cluster import RedisCluster
from apiserver.apierrors.errors.server_error import ConfigError, GeneralError
from apiserver.config_repo import config
@@ -83,7 +83,7 @@ class RedisManager(object):
def host(self, alias):
r = self.connection(alias)
if isinstance(r, RedisCluster):
connections = first(r.connection_pool._available_connections.values())
connections = r.get_default_node().redis_connection.connection_pool._available_connections
else:
connections = r.connection_pool._available_connections

View File

@@ -1,33 +1,36 @@
attrs>=22.1.0
attrs>=22.1.0,<23
azure-storage-blob>=12.13.1
bcrypt>=3.1.4
boltons>=19.1.0
boto3==1.14.13
boto3>=1.26
boto3-stubs[s3]>=1.26
clearml>=1.10.3
dpath>=1.4.2,<2.0
elasticsearch==7.13.3
elasticsearch==7.17.9
fastjsonschema>=2.8
flask-compress>=1.4.0
flask-cors>=3.0.5
flask>=0.12.2
funcsigs==1.0.2
flask>=2.3.2
furl>=2.0.0
gunicorn>=19.7.1
humanfriendly==4.18
jinja2==2.11.3
google-cloud-storage>=2.8.0
gunicorn>=20.1.0
humanfriendly>=4.17
jinja2
jsonmodels>=2.3
jsonschema>=2.6.0
luqum>=0.10.0
mongoengine==0.23.1
mongoengine==0.27.0
nested_dict>=1.61
packaging==20.3
psutil>=5.6.5
pyhocon>=0.3.35
pyhocon>=0.3.35r
pyjwt>=2.4.0
pymongo[srv]==3.12.0
pymongo==4.4.0
python-rapidjson>=0.6.3
redis==3.5.3
redis-py-cluster>=2.1.3
redis>=4.5.4,<5
requests>=2.13.0
semantic_version>=2.8.3,<3
setuptools>=65.5.1
six
tqdm
validators>=0.12.4
validators>=0.12.4
urllib3>=1.26.18

View File

@@ -1,3 +1,43 @@
field_filter {
type: object
description: Filter on a field that includes combination of 'any' or 'all' included and excluded terms
properties {
any {
type: object
description: All the terms in 'any' condition are combined with 'or' operation
properties {
"include" {
type: array
items {type: string}
}
exclude {
type: array
items {type: string}
}
}
}
all {
type: object
description: All the terms in 'all' condition are combined with 'and' operation
properties {
"include" {
type: array
items {type: string}
}
exclude {
type: array
items {type: string}
}
}
}
op {
type: string
description: The operation between 'any' and 'all' parts of the filter if both are provided
default: and
enum: [and, or]
}
}
}
metadata_item {
type: object
properties {
@@ -15,6 +55,35 @@ metadata_item {
}
}
}
task_status_enum {
type: string
enum: [
created
queued
in_progress
stopped
published
publishing
closed
failed
completed
unknown
]
}
multi_field_pattern_data {
type: object
properties {
pattern {
description: "Pattern string (regex)"
type: string
}
fields {
description: "List of field names"
type: array
items { type: string }
}
}
}
credentials {
type: object
properties {

View File

@@ -0,0 +1,141 @@
scalar_key_enum {
type: string
enum: [
iter
timestamp
iso_time
]
}
metric_variants {
type: object
properties {
metric {
description: The metric name
type: string
}
variants {
type: array
description: The names of the metric variants
items {type: string}
}
}
}
debug_images_response_task_metrics {
type: object
properties {
task {
type: string
description: Task ID
}
iterations {
type: array
items {
type: object
properties {
iter {
type: integer
description: Iteration number
}
events {
type: array
items {
type: object
description: Debug image event
}
}
}
}
}
}
}
debug_images_response {
type: object
properties {
scroll_id {
type: string
description: "Scroll ID for getting more results"
}
metrics {
type: array
description: "Debug image events grouped by tasks and iterations"
items {"$ref": "#/definitions/debug_images_response_task_metrics"}
}
}
}
plots_response_task_metrics {
type: object
properties {
task {
type: string
description: Task ID
}
iterations {
type: array
items {
type: object
properties {
iter {
type: integer
description: Iteration number
}
events {
type: array
items {
type: object
description: Plot event
}
}
}
}
}
}
}
plots_response {
type: object
properties {
scroll_id {
type: string
description: "Scroll ID for getting more results"
}
metrics {
type: array
description: "Plot events grouped by tasks and iterations"
items {"$ref": "#/definitions/plots_response_task_metrics"}
}
}
}
single_value_task_metrics {
type: object
properties {
task {
type: string
description: Task ID
}
task_name {
type: string
description: Task name
}
values {
type: array
items {
type: object
properties {
metric { type: string }
variant { type: string}
value { type: number }
timestamp { type: number }
}
}
}
}
}
single_value_metrics_response {
type: object
properties {
tasks {
description: Single value metrics grouped by task
type: array
items {"$ref": "#/definitions/single_value_task_metrics"}
}
}
}

View File

@@ -0,0 +1,506 @@
include "_common.conf"
task_type_enum {
type: string
enum: [
training
testing
inference
data_processing
application
monitor
controller
optimizer
service
qc
custom
]
}
script {
type: object
properties {
binary {
description: "Binary to use when running the script"
type: string
default: python
}
repository {
description: "Name of the repository where the script is located"
type: string
}
tag {
description: "Repository tag"
type: string
}
branch {
description: "Repository branch id If not provided and tag not provided, default repository branch is used."
type: string
}
version_num {
description: "Version (changeset) number. Optional (default is head version) Unused if tag is provided."
type: string
}
entry_point {
description: "Path to execute within the repository"
type: string
}
working_dir {
description: "Path to the folder from which to run the script Default - root folder of repository"
type: string
}
requirements {
description: "A JSON object containing requirements strings by key"
type: object
}
diff {
description: "Uncommitted changes found in the repository when task was run"
type: string
}
}
}
model_type_enum {
type: string
enum: ["input", "output"]
}
task_model_item {
type: object
required: [ name, model]
properties {
name {
description: "The task model name"
type: string
}
model {
description: "The model ID"
type: string
}
}
}
output {
type: object
properties {
destination {
description: "Storage id. This is where output files will be stored."
type: string
}
model {
description: "Model id."
type: string
}
result {
description: "Task result. Values: 'success', 'failure'"
type: string
}
error {
description: "Last error text"
type: string
}
}
}
task_execution_progress_enum {
type: string
enum: [
unknown
running
stopping
stopped
]
}
artifact_type_data {
type: object
properties {
preview {
description: "Description or textual data"
type: string
}
content_type {
description: "System defined raw data content type"
type: string
}
data_hash {
description: "Hash of raw data, without any headers or descriptive parts"
type: string
}
}
}
artifact_mode_enum {
type: string
enum: [
input
output
]
default: output
}
artifact {
type: object
required: [key, type]
properties {
key {
description: "Entry key"
type: string
}
type {
description: "System defined type"
type: string
}
mode {
description: "System defined input/output indication"
"$ref": "#/definitions/artifact_mode_enum"
}
uri {
description: "Raw data location"
type: string
}
content_size {
description: "Raw data length in bytes"
type: integer
}
hash {
description: "Hash of entire raw data"
type: string
}
timestamp {
description: "Epoch time when artifact was created"
type: integer
}
type_data {
description: "Additional fields defined by the system"
"$ref": "#/definitions/artifact_type_data"
}
display_data {
description: "User-defined list of key/value pairs, sorted"
type: array
items {
type: array
items {
type: string # can also be a number... TODO: upgrade the generator
}
}
}
}
}
artifact_id {
type: object
required: [key]
properties {
key {
description: "Entry key"
type: string
}
mode {
description: "System defined input/output indication"
"$ref": "#/definitions/artifact_mode_enum"
}
}
}
task_models {
type: object
properties {
input {
description: "The list of task input models"
type: array
items {"$ref": "#/definitions/task_model_item"}
}
output {
description: "The list of task output models"
type: array
items {"$ref": "#/definitions/task_model_item"}
}
}
}
execution {
type: object
properties {
queue {
description: "Queue ID where task was queued."
type: string
}
parameters {
description: "Json object containing the Task parameters"
type: object
additionalProperties: true
}
model {
description: "Execution input model ID Not applicable for Register (Import) tasks"
type: string
}
model_desc {
description: "Json object representing the Model descriptors"
type: object
additionalProperties: true
}
model_labels {
description: """Json object representing the ids of the labels in the model.
The keys are the layers' names and the values are the IDs.
Not applicable for Register (Import) tasks.
Mandatory for Training tasks"""
type: object
additionalProperties: { type: integer }
}
framework {
description: """Framework related to the task. Case insensitive. Mandatory for Training tasks. """
type: string
}
docker_cmd {
description: "Command for running docker script for the execution of the task"
type: string
}
artifacts {
description: "Task artifacts"
type: array
items { "$ref": "#/definitions/artifact" }
}
}
}
last_metrics_event {
type: object
properties {
metric {
description: "Metric name"
type: string
}
variant {
description: "Variant name"
type: string
}
value {
description: "Last value reported"
type: number
}
min_value {
description: "Minimum value reported"
type: number
}
min_value_iteration {
description: "The iteration at which the minimum value was reported"
type: integer
}
max_value {
description: "Maximum value reported"
type: number
}
max_value_iteration {
description: "The iteration at which the maximum value was reported"
type: integer
}
}
}
last_metrics_variants {
type: object
description: "Last metric events, one for each variant hash"
additionalProperties {
"$ref": "#/definitions/last_metrics_event"
}
}
params_item {
type: object
properties {
section {
description: "Section that the parameter belongs to"
type: string
}
name {
description: "Name of the parameter. The combination of section and name should be unique"
type: string
}
value {
description: "Value of the parameter"
type: string
}
type {
description: "Type of the parameter. Optional"
type: string
}
description {
description: "The parameter description. Optional"
type: string
}
}
}
configuration_item {
type: object
properties {
name {
description: "Name of the parameter. Should be unique"
type: string
}
value {
description: "Value of the parameter"
type: string
}
type {
description: "Type of the parameter. Optional"
type: string
}
description {
description: "The parameter description. Optional"
type: string
}
}
}
section_params {
description: "Task section params"
type: object
additionalProperties {
"$ref": "#/definitions/params_item"
}
}
task {
type: object
properties {
id {
description: "Task id"
type: string
}
name {
description: "Task Name"
type: string
}
user {
description: "Associated user id"
type: string
}
company {
description: "Company ID"
type: string
}
type {
description: "Type of task. Values: 'training', 'testing'"
"$ref": "#/definitions/task_type_enum"
}
status {
description: ""
"$ref": "#/definitions/task_status_enum"
}
comment {
description: "Free text comment"
type: string
}
created {
description: "Task creation time (UTC) "
type: string
format: "date-time"
}
started {
description: "Task start time (UTC)"
type: string
format: "date-time"
}
completed {
description: "Task end time (UTC)"
type: string
format: "date-time"
}
active_duration {
description: "Task duration time (seconds)"
type: integer
}
parent {
description: "Parent task id"
type: string
}
project {
description: "Project ID of the project to which this task is assigned"
type: string
}
output {
description: "Task output params"
"$ref": "#/definitions/output"
}
execution {
description: "Task execution params"
"$ref": "#/definitions/execution"
}
container {
description: "Docker container parameters"
type: object
additionalProperties { type: string }
}
models {
description: "Task models"
"$ref": "#/definitions/task_models"
}
// TODO: will be removed
script {
description: "Script info"
"$ref": "#/definitions/script"
}
tags {
description: "User-defined tags list"
type: array
items { type: string }
}
system_tags {
description: "System tags list. This field is reserved for system use, please don't use it."
type: array
items { type: string }
}
status_changed {
description: "Last status change time"
type: string
format: "date-time"
}
status_message {
description: "free text string representing info about the status"
type: string
}
status_reason {
description: "Reason for last status change"
type: string
}
published {
description: "Task publish time"
type: string
format: "date-time"
}
last_worker {
description: "ID of last worker that handled the task"
type: string
}
last_worker_report {
description: "Last time a worker reported while working on this task"
type: string
format: "date-time"
}
last_update {
description: "Last time this task was created, edited, changed or events for this task were reported"
type: string
format: "date-time"
}
last_change {
description: "Last time any update was done to the task"
type: string
format: "date-time"
}
last_iteration {
description: "Last iteration reported for this task"
type: integer
}
last_metrics {
description: "Last metric variants (hash to events), one for each metric hash"
type: object
additionalProperties {
"$ref": "#/definitions/last_metrics_variants"
}
}
hyperparams {
description: "Task hyper params per section"
type: object
additionalProperties {
"$ref": "#/definitions/section_params"
}
}
configuration {
description: "Task configuration params"
type: object
additionalProperties {
"$ref": "#/definitions/configuration_item"
}
}
runtime {
description: "Task runtime mapping"
type: object
additionalProperties: true
}
}
}

View File

@@ -1,17 +1,6 @@
_description : "Provides an API for running tasks to report events collected by the system."
_definitions {
metric_variants {
type: object
metric {
description: The metric name
type: string
}
variants {
type: array
description: The names of the metric variants
items {type: string}
}
}
include "_events_common.conf"
metrics_scalar_event {
description: "Used for reporting scalar metrics during training task"
type: object
@@ -22,8 +11,8 @@ _definitions {
type: number
}
type {
description: "training_stats_vector"
const: "training_stats_scalar"
description: "'training_stats_scalar'"
type: string
}
task {
description: "Task ID (required)"
@@ -57,8 +46,8 @@ _definitions {
type: number
}
type {
description: "training_stats_vector"
const: "training_stats_vector"
description: "'training_stats_vector'"
type: string
}
task {
description: "Task ID (required)"
@@ -93,8 +82,8 @@ _definitions {
type: number
}
type {
description: ""
const: "training_debug_image"
description: "'training_debug_image'"
type: string
}
task {
description: "Task ID (required)"
@@ -134,7 +123,7 @@ _definitions {
}
type {
description: "'plot'"
const: "plot"
type: string
}
task {
description: "Task ID (required)"
@@ -164,14 +153,6 @@ _definitions {
}
}
}
scalar_key_enum {
type: string
enum: [
iter
timestamp
iso_time
]
}
log_level_enum {
type: string
enum: [
@@ -240,7 +221,7 @@ _definitions {
}
type {
description: "'log'"
const: "log"
type: string
}
task {
description: "Task ID (required)"
@@ -260,90 +241,6 @@ _definitions {
}
}
}
debug_images_response_task_metrics {
type: object
properties {
task {
type: string
description: Task ID
}
iterations {
type: array
items {
type: object
properties {
iter {
type: integer
description: Iteration number
}
events {
type: array
items {
type: object
description: Debug image event
}
}
}
}
}
}
}
debug_images_response {
type: object
properties {
scroll_id {
type: string
description: "Scroll ID for getting more results"
}
metrics {
type: array
description: "Debug image events grouped by tasks and iterations"
items {"$ref": "#/definitions/debug_images_response_task_metrics"}
}
}
}
plots_response_task_metrics {
type: object
properties {
task {
type: string
description: Task ID
}
iterations {
type: array
items {
type: object
properties {
iter {
type: integer
description: Iteration number
}
events {
type: array
items {
type: object
description: Plot event
}
}
}
}
}
}
}
plots_response {
type: object
properties {
scroll_id {
type: string
description: "Scroll ID for getting more results"
}
metrics {
type: array
description: "Plot events grouped by tasks and iterations"
items {"$ref": "#/definitions/plots_response_task_metrics"}
}
}
}
debug_image_sample_response {
type: object
properties {
@@ -547,7 +444,7 @@ debug_images {
}
total {
type: number
description: "Total number of results available for this query"
description: "Total number of results available for this query. In case there are more than 10000 results it is set to 10000"
}
scroll_id {
type: string
@@ -601,7 +498,7 @@ debug_images {
}
}
"2.22": ${debug_images."2.14"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -624,7 +521,7 @@ plots {
}
iters {
type: integer
description: "Max number of latest iterations for which to return debug images"
description: "Max number of latest iterations for which to return plots"
}
navigate_earlier {
type: boolean
@@ -643,7 +540,7 @@ plots {
response {"$ref": "#/definitions/plots_response"}
}
"2.22": ${plots."2.20"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model plots. Otherwise task plots
default: false
@@ -693,7 +590,7 @@ get_debug_image_sample {
}
}
"2.22": ${get_debug_image_sample."2.20"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model debug images. Otherwise task debug images
default: false
@@ -730,7 +627,7 @@ next_debug_image_sample {
default: false
description: If set then navigate to the next/previous iteration
}
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model debug images. Otherwise task debug images
default: false
@@ -774,7 +671,7 @@ get_plot_sample {
response {"$ref": "#/definitions/plot_sample_response"}
}
"2.22": ${get_plot_sample."2.20"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model plots. Otherwise task plots
default: false
@@ -811,7 +708,7 @@ next_plot_sample {
default: false
description: If set then navigate to the next/previous iteration
}
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model plots. Otherwise task plots
default: false
@@ -850,7 +747,7 @@ get_task_metrics{
}
}
"2.22": ${get_task_metrics."2.7"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then get metrics from model events. Otherwise from task events
default: false
@@ -955,7 +852,7 @@ get_task_log {
}
total {
type: number
description: "Total number of results available for this query"
description: "Total number of results available for this query. In case there are more than 10000 results it is set to 10000"
}
scroll_id {
type: string
@@ -1009,7 +906,7 @@ get_task_log {
}
total {
type: number
description: "Total number of log events available for this query"
description: "Total number of log events available for this query. In case there are more than 10000 events it is set to 10000"
}
}
}
@@ -1064,7 +961,7 @@ get_task_events {
}
total {
type: number
description: "Total number of results available for this query"
description: "Total number of results available for this query. In case there are more than 10000 results it is set to 10000"
}
scroll_id {
type: string
@@ -1074,7 +971,7 @@ get_task_events {
}
}
"2.22": ${get_task_events."2.1"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then get retrieving model events. Otherwise task events
default: false
@@ -1130,7 +1027,7 @@ get_task_plots {
}
iters {
type: integer
description: "Max number of latest iterations for which to return debug images"
description: "Max number of latest iterations for which to return plots"
}
scroll_id {
type: string
@@ -1154,7 +1051,7 @@ get_task_plots {
}
total {
type: number
description: "Total number of results available for this query"
description: "Total number of results available for this query. In case there are more than 10000 results it is set to 10000"
}
scroll_id {
type: string
@@ -1182,7 +1079,7 @@ get_task_plots {
}
}
"2.22": ${get_task_plots."2.16"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1208,7 +1105,7 @@ get_multi_task_plots {
}
iters {
type: integer
description: "Max number of latest iterations for which to return debug images"
description: "Max number of latest iterations for which to return plots"
}
scroll_id {
type: string
@@ -1229,7 +1126,7 @@ get_multi_task_plots {
}
total {
type: number
description: "Total number of results available for this query"
description: "Total number of results available for this query. In case there are more than 10000 results it is set to 10000"
}
scroll_id {
type: string
@@ -1246,12 +1143,19 @@ get_multi_task_plots {
}
}
"2.22": ${get_multi_task_plots."2.16"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
}
}
"2.26": ${get_multi_task_plots."2.22"} {
request.properties.last_iters_per_task_metric {
type: boolean
description: If set to 'true' and iters passed then last iterations for each task metrics are retrieved. Otherwise last iterations for the whole task are retrieved
default: true
}
}
}
get_vector_metrics_and_variants {
"2.1" {
@@ -1281,7 +1185,7 @@ get_vector_metrics_and_variants {
}
}
"2.22": ${get_vector_metrics_and_variants."2.1"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1326,7 +1230,7 @@ vector_metrics_iter_histogram {
}
}
"2.22": ${vector_metrics_iter_histogram."2.1"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1386,7 +1290,7 @@ scalar_metrics_iter_histogram {
}
}
"2.22": ${scalar_metrics_iter_histogram."2.14"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1403,7 +1307,7 @@ multi_task_scalar_metrics_iter_histogram {
]
properties {
tasks {
description: "List of task Task IDs. Maximum amount of tasks is 10"
description: "List of task Task IDs. Maximum amount of tasks is 100"
type: array
items {
type: string
@@ -1432,7 +1336,7 @@ multi_task_scalar_metrics_iter_histogram {
}
}
"2.22": ${multi_task_scalar_metrics_iter_histogram."2.1"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1456,39 +1360,10 @@ get_task_single_value_metrics {
}
}
}
response {
type: object
properties {
tasks {
description: Single value metrics grouped by task
type: array
items {
type: object
properties {
task {
type: string
description: Task ID
}
values {
type: array
items {
type: object
properties {
metric { type: string }
variant { type: string}
value { type: number }
timestamp { type: number }
}
}
}
}
}
}
}
}
response {"$ref": "#/definitions/single_value_metrics_response"}
}
"2.22": ${get_task_single_value_metrics."2.20"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1574,7 +1449,7 @@ get_scalar_metrics_and_variants {
}
}
"2.22": ${get_scalar_metrics_and_variants."2.1"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1595,6 +1470,10 @@ get_scalar_metric_data {
type: string
description: type of metric
}
scroll_id {
type: string
description: "Scroll ID of previous call (used for getting more results)"
}
}
}
response {
@@ -1617,7 +1496,7 @@ get_scalar_metric_data {
}
scroll_id {
type: string
description: "Scroll ID of previous call (used for getting more results)"
description: "Scroll ID for getting more results"
}
}
}
@@ -1630,7 +1509,7 @@ get_scalar_metric_data {
}
}
"2.22": ${get_scalar_metric_data."2.16"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1701,7 +1580,7 @@ scalar_metrics_iter_raw {
}
}
"2.22": ${scalar_metrics_iter_raw."2.16"} {
model_events {
request.properties.model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
@@ -1762,4 +1641,4 @@ clear_task_log {
}
}
}
}
}

View File

@@ -6,7 +6,7 @@ _default {
}
supported_modes {
authorize: false
authorize: null
"2.9" {
description: """ Return supported login modes."""
request {
@@ -59,7 +59,7 @@ supported_modes {
description: "SSO authentication providers"
type: object
additionalProperties {
desctiprion: "Provider redirect URL"
description: "Provider redirect URL"
type: string
}
}
@@ -95,7 +95,7 @@ supported_modes {
}
logout {
authorize: false
authorize: null
allow_roles = [ "*" ]
"2.13" {
description: """ Logout (including SSO, if used)) """

View File

@@ -1,6 +1,6 @@
_description: """This service provides a management interface for models (results of training tasks) stored in the system."""
_definitions {
include "_common.conf"
include "_tasks_common.conf"
multi_field_pattern_data {
type: object
properties {
@@ -104,6 +104,17 @@ _definitions {
"$ref": "#/definitions/metadata_item"
}
}
last_iteration {
description: "Last iteration reported for this model"
type: integer
}
last_metrics {
description: "Last metric variants (hash to events), one for each metric hash"
type: object
additionalProperties {
"$ref": "#/definitions/last_metrics_variants"
}
}
stats {
description: "Model statistics"
type: object
@@ -241,6 +252,23 @@ get_all_ex {
default: false
}
}
"2.23": ${get_all_ex."2.20"} {
request.properties {
allow_public {
description: "Allow public models to be returned in the results"
type: boolean
default: true
}
}
}
"2.27": ${get_all_ex."2.23"} {
request.properties {
filters {
type: object
additionalProperties: ${_definitions.field_filter}
}
}
}
}
get_all {
"2.1" {
@@ -337,9 +365,6 @@ get_all {
"$ref": "#/definitions/multi_field_pattern_data"
}
}
dependencies {
page: [ page_size ]
}
}
response {
type: object
@@ -375,6 +400,17 @@ get_all {
description: "Scroll ID that can be used with the next calls to get_all to retrieve more data"
}
}
"2.26": ${get_all."2.15"} {
request {
properties {
include_subprojects {
description: "If set to 'true' and project field is set then models from the subprojects are searched too"
type: boolean
default: false
}
}
}
}
}
get_frameworks {
"2.8" {
@@ -976,7 +1012,7 @@ move {
items { type: string }
}
project {
description: "Target project ID. If not provided, `project_name` must be provided."
description: "Target project ID. If not provided, `project_name` must be provided. Use null for the root project"
type: string
}
project_name {
@@ -1055,4 +1091,38 @@ delete_metadata {
}
}
}
}
update_tags {
"2.27" {
description: Add or remove tags from multiple models
request {
type: object
properties {
ids {
type: array
description: IDs of the models to update
items {type: string}
}
add_tags {
type: array
description: User tags to add
items {type: string}
}
remove_tags {
type: array
description: User tags to remove
items {type: string}
}
}
}
response {
type: object
properties {
updated {
type: integer
description: The number of updated models
}
}
}
}
}

View File

@@ -1,5 +1,38 @@
_description: "This service provides organization level operations"
_definitions {
value_mapping {
type: object
required: [key, value]
properties {
key {
description: Original value
type: object
}
value {
description: Translated value
type: object
}
}
}
field_mapping {
type: object
required: [field]
properties {
field {
description: The source field name as specified in the only_fields
type: string
}
name {
description: The column name in the exported csv file
type: string
}
values {
type: array
items { "$ref": "#/definitions/value_mapping"}
}
}
}
}
get_tags {
"2.8" {
description: "Get all the user and system tags used for the company tasks and models"
@@ -170,10 +203,96 @@ get_entities_count {
default: false
}
active_users {
descritpion: "The list of users that were active in the project. If passes then the resulting projects are filtered to the ones that have tasks created by these users"
description: "The list of users that were active in the project. If passes then the resulting projects are filtered to the ones that have tasks created by these users"
type: array
items: {type: string}
}
}
}
"2.23": ${get_entities_count."2.22"} {
request.properties {
reports {
type: object
additionalProperties: true
description: Search criteria for reports
}
allow_public {
description: "Allow public entities to be counted in the results"
type: boolean
default: true
}
}
response.properties {
reports {
type: integer
description: The number of reports matching the criteria
}
}
}
}
prepare_download_for_get_all {
"2.26": {
description: Prepares download from get_all_ex parameters
request {
type: object
required: [ entity_type, only_fields, field_mappings]
properties {
only_fields {
description: "List of task field names (nesting is supported using '.', e.g. execution.model_labels). If provided, this list defines the query's projection (only these fields will be returned for each result entry)"
type: array
items {type: string}
}
allow_public {
description: "Allow public entities to be returned in the results"
type: boolean
default: true
}
search_hidden {
description: "If set to 'true' then hidden entities are included in the search results"
type: boolean
default: false
}
entity_type {
description: "The type of the entity to retrieve"
type: string
enum: [
task
model
]
}
field_mappings {
description: The name and value mappings for the exported fields. The fields that are not in the mappings will not be exported
type: array
items { "$ref": "#/definitions/field_mapping"}
}
}
}
response {
type: object
properties {
prepare_id {
description: "Prepare ID (use when calling 'download_for_get_all')"
type: string
}
}
}
}
}
download_for_get_all {
"2.26": {
description: Generates a file for the download
request {
type: object
required: [ prepare_id ]
properties {
prepare_id {
description: "Call ID returned by a call to prepare_download_for_get_all"
type: string
}
}
}
response {
type: string
}
}
}

View File

@@ -1,7 +1,42 @@
_description: "Provides a management API for pipelines in the system."
_definitions {
include "_common.conf"
}
delete_runs {
"2.26": ${_definitions.batch_operation} {
description: Delete pipeline runs
request {
required: [ids, project]
properties {
ids.description: "IDs of the pipeline runs to delete. Should be the ids of pipeline controller tasks"
project {
description: "Pipeline project ids. When deleting at least one run should be left"
type: string
}
}
}
response {
properties {
succeeded.items.properties.deleted {
description: "Indicates whether the task was deleted"
type: boolean
}
succeeded.items.properties.updated_children {
description: "Number of child tasks whose parent property was updated"
type: integer
}
succeeded.items.properties.updated_models {
description: "Number of models whose task property was updated"
type: integer
}
succeeded.items.properties.deleted_models {
description: "Number of deleted output models"
type: integer
}
}
}
}
}
start_pipeline {
"2.17" {
description: "Start a pipeline"
@@ -24,7 +59,7 @@ start_pipeline {
type: object
properties {
name: { type: string }
value: { type: [string, null] }
value: { type: string }
}
}
}

View File

@@ -1,5 +1,6 @@
_description: "Provides support for defining Projects containing Tasks, Models and Dataset Versions."
_definitions {
include "_common.conf"
multi_field_pattern_data {
type: object
properties {
@@ -61,12 +62,26 @@ _definitions {
type: string
}
last_update {
description: """Last project update time. Reflects the last time the project metadata was changed or a task in this project has changed status"""
description: "Last project update time. Reflects the last time the project metadata was changed or a task in this project has changed status"
type: string
format: "date-time"
}
}
}
stats_datasets {
type: object
properties {
count {
description: Number of datasets
type: integer
}
tags {
description: Dataset tags
type: array
items {type: string}
}
}
}
stats_status_count {
type: object
properties {
@@ -141,6 +156,10 @@ _definitions {
description: "Stats for archived tasks"
"$ref": "#/definitions/stats_status_count"
}
datasets {
description: "Stats for childrent datasets"
"$ref": "#/definitions/stats_datasets"
}
}
}
projects_get_all_response_single {
@@ -191,11 +210,15 @@ _definitions {
type: string
}
last_update {
description: """Last project update time. Reflects the last time the project metadata was changed or a task in this project has changed status"""
description: "Last project update time. Reflects the last time the project metadata was changed or a task in this project has changed status"
type: string
format: "date-time"
}
// extra properties
hidden {
description: "Returned if the search_hidden flag was specified in the get_all_ex call and the project is hidden"
type: boolean
}
stats {
description: "Additional project stats"
"$ref": "#/definitions/stats"
@@ -217,6 +240,10 @@ _definitions {
}
}
}
own_datasets {
description: "The amount of datasets/hyperdatasers under this project (without children projects). Returned if 'check_own_contents' flag is set in the request and children_type is set to 'dataset' or 'hyperdataset'"
type: integer
}
own_tasks {
description: "The amount of tasks under this project (without children projects). Returned if 'check_own_contents' flag is set in the request"
type: integer
@@ -543,7 +570,7 @@ get_all_ex {
request {
properties {
active_users {
descritpion: "The list of users that were active in the project. If passes then the resulting projects are filtered to the ones that have tasks created by these users"
description: "The list of users that were active in the project. If passes then the resulting projects are filtered to the ones that have tasks created by these users"
type: array
items: {type: string}
}
@@ -611,6 +638,38 @@ get_all_ex {
default: false
}
}
"2.23": ${get_all_ex."2.20"} {
request.properties {
allow_public {
description: "Allow public projects to be returned in the results"
type: boolean
default: true
}
}
}
"2.24": ${get_all_ex."2.23"} {
request.properties.children_type {
description: If specified that only the projects under which the entities of this type can be found will be returned
type: string
enum: [pipeline, report, dataset]
}
}
"2.25": ${get_all_ex."2.24"} {
request.properties.children_tags {
description: "The list of tag values to filter children by. Takes effect only if children_type is set. Use 'null' value to specify empty tags. Use '__Snot' value to specify that the following value should be excluded"
type: array
items {type: string}
}
}
"2.27": ${get_all_ex."2.25"} {
request.properties {
filters {
type: object
additionalProperties: ${_definitions.field_filter}
}
children_tags_filter: ${_definitions.field_filter}
}
}
}
update {
"2.1" {
@@ -759,6 +818,26 @@ validate_delete {
}
}
}
"2.26": ${validate_delete."2.14"} {
response.properties {
reports {
description: "The total number of reports under the project and all its children"
type: integer
}
non_archived_reports {
description: "The total number of non-archived reports under the project and all its children"
type: integer
}
pipelines {
description: "The total number of pipelines with active controllers under the project and all its children"
type: integer
}
datasets {
description: "The total number of non-empty datasets under the project and all its children"
type: integer
}
}
}
}
delete {
"2.1" {
@@ -819,6 +898,13 @@ delete {
}
}
}
"2.26": ${delete."2.13"} {
request.properties.delete_external_artifacts {
description: "If set to 'true' then BE will try to delete the extenal artifacts associated with the project tasks and models from the fileserver (if configured to do so)"
type: boolean
default: true
}
}
}
get_unique_metric_variants {
"2.1" {
@@ -856,6 +942,13 @@ get_unique_metric_variants {
}
}
}
"2.25": ${get_unique_metric_variants."2.13"} {
request.properties.model_metrics {
description: If set to true then bring unique metric and variant names from the project models otherwise from the project tasks
type: boolean
default: false
}
}
}
get_hyperparam_values {
"2.13" {
@@ -903,6 +996,26 @@ get_hyperparam_values {
}
}
}
"2.26": ${get_hyperparam_values."2.13"} {
request.properties {
page {
description: "Page number"
default: 0
type: integer
}
page_size {
description: "Page size"
default: 500
type: integer
}
}
}
"2.27": ${get_hyperparam_values."2.26"} {
request.properties.pattern {
type: string
description: The search pattern regex
}
}
}
get_hyper_parameters {
"2.9" {
@@ -1000,6 +1113,20 @@ get_model_metadata_values {
}
}
}
"2.26": ${get_model_metadata_values."2.17"} {
request.properties {
page {
description: "Page number"
default: 0
type: integer
}
page_size {
description: "Page size"
default: 500
type: integer
}
}
}
}
get_model_metadata_keys {
"2.17" {
@@ -1159,13 +1286,15 @@ get_task_parents {
}
project {
type: object
id {
description: "The ID of the parent task project"
type: string
}
name {
description: "The name of the parent task project"
type: string
properties {
id {
description: "The ID of the parent task project"
type: string
}
name {
description: "The name of the parent task project"
type: string
}
}
}
}
@@ -1185,4 +1314,58 @@ get_task_parents {
}
}
}
"2.25": ${get_task_parents."2.13"} {
request.properties.task_name {
description: Task name pattern for the returned parent tasks
type: string
}
}
}
get_user_names {
"2.26" {
description: "Get names and ids of the users who created child entitites under the passed projects"
request {
type: object
properties {
projects {
description: "The list of projects. If not passed or empty then all the projects are searched"
type: array
items { type: string }
}
include_subprojects {
description: "If set to 'true' and the projects field is not empty then the result includes user name from the subprojects children"
type: boolean
default: true
}
entity {
description: The type of the child entity to look for
type: string
enum: [task, model]
default: task
}
}
}
response {
type: object
properties {
users {
description: "The list of users sorted by their names"
type: array
items {
type: object
properties {
id {
description: "The ID of the user"
type: string
}
name {
description: "The name of the user"
type: string
}
}
}
}
}
}
}
}

View File

@@ -159,6 +159,14 @@ get_all_ex {
default: false
}
}
"2.27": ${get_all_ex."2.21"} {
request.properties {
filters {
type: object
additionalProperties: ${_definitions.field_filter}
}
}
}
}
get_all {
"2.4" {

View File

@@ -0,0 +1,747 @@
_description: "Provides a management API for reports in the system."
_definitions {
include "_tasks_common.conf"
include "_events_common.conf"
update_response {
type: object
properties {
updated {
description: "Number of reports updated (0 or 1)"
type: integer
enum: [ 0, 1 ]
}
fields {
description: "Updated fields names and values"
type: object
additionalProperties: true
}
}
}
report_status_enum {
type: string
enum: [
created
published
]
}
report {
type: object
properties {
id {
description: "Report id"
type: string
}
name {
description: "Report Name"
type: string
}
user {
description: "Associated user id"
type: string
}
company {
description: "Company ID"
type: string
}
status {
description: ""
"$ref": "#/definitions/report_status_enum"
}
comment {
description: "Free text comment"
type: string
}
report {
description: "Report template"
type: string
}
report_assets {
description: "List of the external report assets"
type: array
items { type: string }
}
created {
description: "Report creation time (UTC) "
type: string
format: "date-time"
}
project {
description: "Project ID of the project to which this report is assigned"
type: string
}
tags {
description: "User-defined tags list"
type: array
items { type: string }
}
system_tags {
description: "System tags list. This field is reserved for system use, please don't use it."
type: array
items { type: string }
}
status_changed {
description: "Last status change time"
type: string
format: "date-time"
}
status_message {
description: "free text string representing info about the status"
type: string
}
status_reason {
description: "Reason for last status change"
type: string
}
published {
description: "Report publish time"
type: string
format: "date-time"
}
last_update {
description: "Last time this report was created, edited, changed"
type: string
format: "date-time"
}
}
}
}
create {
"2.23" {
description: "Create a new report"
request {
type: object
required: [
name
]
properties {
name {
description: "Report name. Unique within the company."
type: string
}
tags {
description: "User-defined tags list"
type: array
items { type: string }
}
comment {
description: "Free text comment "
type: string
}
report {
description: "Report template"
type: string
}
project {
description: "Project ID of the project to which this report is assigned Must exist[ab]"
type: string
}
}
}
response {
type: object
properties {
id {
description: "ID of the report"
type: string
}
project_id {
description: "ID of the project that the report belongs to"
type: string
}
}
}
}
"2.24": ${create."2.23"} {
request.properties.report_assets {
description: "List of the external report assets"
type: array
items { type: string }
}
}
}
update {
"2.23" {
description: "Create a new report"
request {
type: object
required: [
task
]
properties {
task {
description: "The ID of the report task to update"
type: string
}
name {
description: "Report name. Unique within the company."
type: string
}
tags {
description: "User-defined tags list"
type: array
items { type: string }
}
comment {
description: "Free text comment "
type: string
}
report {
description: "Report template"
type: string
}
}
}
response: ${_definitions.update_response}
}
"2.24": ${update."2.23"} {
request.properties.report_assets {
description: "List of the external report assets"
type: array
items { type: string }
}
}
}
move {
"2.23" {
description: "Move reports to a project"
request {
type: object
required: [task]
properties {
task {
description: "ID of the report to move"
type: string
}
project {
description: "Target project ID. If not provided, `project_name` must be provided. Use null for the root project"
type: string
}
project_name {
description: "Target project name. If provided and a project with this name does not exist, a new project will be created. If not provided, `project` must be provided."
type: string
}
}
}
response {
type: object
properties {
project_id: {
description: The ID of the target project
type: string
}
}
}
}
}
publish {
"2.23" {
description: "Publish report"
request {
type: object
required: [
task
]
properties {
task {
description: "The ID of the report task to publish"
type: string
}
comment {
description: "The client message"
type: string
}
}
}
response: ${_definitions.update_response}
}
}
archive {
"2.23" {
description: "Archive report"
request {
type: object
required: [
task
]
properties {
task {
description: "The ID of the report task to archive"
type: string
}
comment {
description: "The client message"
type: string
}
}
}
response {
type: object
properties {
archived {
description: "Number of reports archived (0 or 1)"
type: integer
enum: [0, 1]
}
}
}
}
}
unarchive {
"2.23" {
description: "Unarchive report"
request {
type: object
required: [
task
]
properties {
task {
description: "The ID of the report task to unarchive"
type: string
}
comment {
description: "The client message"
type: string
}
}
}
response {
type: object
properties {
unarchived {
description: "Number of reports unarchived (0 or 1)"
type: integer
enum: [0, 1]
}
}
}
}
}
//share {
// "999.0" {
// description: "Share or unshare report"
// request {
// type: object
// required: [
// task
// ]
// properties {
// task {
// description: "The ID of the report task to share/unshare"
// type: string
// }
// share {
// description: "If set to 'true' then the report will be shared. Otherwise unshared."
// type: boolean
// default: true
// }
// }
// }
// response {
// type: object
// properties {
// changed {
// description: "Number of changed reports (0 or 1)"
// type: integer
// enum: [0, 1]
// }
// }
// }
// }
//}
delete {
"2.23" {
description: "Delete report"
request {
type: object
required: [
task
]
properties {
task {
description: "The ID of the report task to delete"
type: string
}
force {
description: "If not set then published or unarchived reports cannot be deleted"
type: boolean
default: false
}
}
}
response {
type: object
properties {
deleted {
description: "Number of deleted reports (0 or 1)"
type: integer
enum: [0, 1]
}
}
}
}
}
get_task_data {
"2.23" {
description: "Get the tasks data according the passed search criteria + requested events"
request {
type: object
properties {
id {
description: "List of IDs to filter by"
type: array
items { type: string }
}
name {
description: "Get only tasks whose name matches this pattern (python regular expression syntax)"
type: string
}
user {
description: "List of user IDs used to filter results by the task's creating user"
type: array
items { type: string }
}
size {
type: integer
minimum: 1
description: "The number of tasks to retrieve"
}
order_by {
description: "List of field names to order by. When search_text is used, '@text_score' can be used as a field representing the text score of returned documents. Use '-' prefix to specify descending order. Optional, recommended when using page"
type: array
items { type: string }
}
type {
description: "List of task types. One or more of: 'import', 'annotation', 'training' or 'testing' (case insensitive)"
type: array
items { type: string }
}
tags {
description: "List of task user-defined tags. Use '-' prefix to exclude tags"
type: array
items { type: string }
}
system_tags {
description: "List of task system tags. Use '-' prefix to exclude system tags"
type: array
items { type: string }
}
status {
description: "List of task status."
type: array
items { "$ref": "#/definitions/task_status_enum" }
}
project {
description: "List of project IDs"
type: array
items { type: string }
}
only_fields {
description: "List of task field names (nesting is supported using '.', e.g. execution.model_labels). If provided, this list defines the query's projection (only these fields will be returned for each result entry)"
type: array
items { type: string }
}
parent {
description: "Parent ID"
type: string
}
status_changed {
description: "List of status changed constraint strings (utcformat, epoch) with an optional prefix modifier (>, >=, <, <=)"
type: array
items {
type: string
pattern: "^(>=|>|<=|<)?.*$"
}
}
search_text {
description: "Free text search query"
type: string
}
allow_public {
description: "Allow public tasks to be returned in the results"
type: boolean
default: true
}
_all_ {
description: "Multi-field pattern condition (all fields match pattern)"
"$ref": "#/definitions/multi_field_pattern_data"
}
_any_ {
description: "Multi-field pattern condition (any field matches pattern)"
"$ref": "#/definitions/multi_field_pattern_data"
}
"input.view.entries.dataset" {
description: "List of input dataset IDs"
type: array
items { type: string }
}
"input.view.entries.version" {
description: "List of input dataset version IDs"
type: array
items { type: string }
}
search_hidden {
description: "If set to 'true' then hidden tasks are included in the search results"
type: boolean
default: false
}
include_subprojects {
description: "If set to 'true' and project field is set then tasks from the subprojects are searched too"
type: boolean
default: false
}
plots {
type: object
properties {
iters {
type: integer
description: "Max number of latest iterations for which to return plots"
}
metrics {
type: array
description: List of metrics and variants
items { "$ref": "#/definitions/metric_variants" }
}
}
}
debug_images {
type: object
properties {
iters {
type: integer
description: "Max number of latest iterations for which to return debug images"
}
metrics {
type: array
description: List of metrics and variants
items { "$ref": "#/definitions/metric_variants" }
}
}
}
scalar_metrics_iter_histogram {
type: object
properties {
samples {
description: "The amount of histogram points to return (0 to return all the points). Optional, the default value is 6000."
type: integer
}
key {
description: """
Histogram x axis to use:
iter - iteration number
iso_time - event time as ISO formatted string
timestamp - event timestamp as milliseconds since epoch
"""
"$ref": "#/definitions/scalar_key_enum"
}
metrics {
type: array
description: List of metrics and variants
items { "$ref": "#/definitions/metric_variants" }
}
}
}
}
}
response {
type: object
properties {
tasks {
description: "List of tasks"
type: array
items { "$ref": "#/definitions/task" }
}
plots {
type: object
description: "Plots mapped by metric, variant, task and iteration"
additionalProperties: true
}
debug_images {
type: array
description: "Debug image events grouped by tasks and iterations"
items {"$ref": "#/definitions/debug_images_response_task_metrics"}
}
scalar_metrics_iter_histogram {
type: object
additionalProperties: true
}
}
}
}
"2.25": ${get_task_data."2.23"} {
request.properties {
model_events {
type: boolean
description: If set then the retrieving model events. Otherwise task events
default: false
}
single_value_metrics {
type: object
description: If passed then task single value metrics are returned
additionalProperties: false
}
}
response.properties.single_value_metrics {
type: array
description: Single value metrics grouped by task
items {"$ref": "#/definitions/single_value_task_metrics"}
}
}
"2.26": ${get_task_data."2.25"} {
request.properties.plots.properties.last_iters_per_task_metric {
type: boolean
description: If set to 'true' and iters passed then last iterations for each task metrics are retrieved. Otherwise last iterations for the whole task are retrieved
default: true
}
}
}
get_all_ex {
"2.23" {
description: "Get all the company's and public report tasks"
request {
type: object
properties {
id {
description: "List of IDs to filter by"
type: array
items { type: string }
}
name {
description: "Get only reports whose name matches this pattern (python regular expression syntax)"
type: string
}
user {
description: "List of user IDs used to filter results by the reports's creating user"
type: array
items { type: string }
}
page {
description: "Page number, returns a specific page out of the resulting list of reports"
type: integer
minimum: 0
}
page_size {
description: "Page size, specifies the number of results returned in each page (last page may contain fewer results)"
type: integer
minimum: 1
}
order_by {
description: "List of field names to order by. When search_text is used, '@text_score' can be used as a field representing the text score of returned documents. Use '-' prefix to specify descending order. Optional, recommended when using page"
type: array
items { type: string }
}
tags {
description: "List of report user-defined tags. Use '-' prefix to exclude tags"
type: array
items { type: string }
}
system_tags {
description: "List of report system tags. Use '-' prefix to exclude system tags"
type: array
items { type: string }
}
status {
description: "List of report status."
type: array
items { "$ref": "#/definitions/report_status_enum" }
}
project {
description: "List of project IDs"
type: array
items { type: string }
}
only_fields {
description: "List of report field names (nesting is supported using '.'). If provided, this list defines the query's projection (only these fields will be returned for each result entry)"
type: array
items { type: string }
}
status_changed {
description: "List of status changed constraint strings (utcformat, epoch) with an optional prefix modifier (>, >=, <, <=)"
type: array
items {
type: string
pattern: "^(>=|>|<=|<)?.*$"
}
}
search_text {
description: "Free text search query"
type: string
}
scroll_id {
type: string
description: "Scroll ID returned from the previos calls to get_all"
}
refresh_scroll {
type: boolean
description: "If set then all the data received with this scroll will be requeried"
}
size {
type: integer
minimum: 1
description: "The number of tasks to retrieve"
}
allow_public {
description: "Allow public reports to be returned in the results"
type: boolean
default: true
}
_all_ {
description: "Multi-field pattern condition (all fields match pattern)"
"$ref": "#/definitions/multi_field_pattern_data"
}
_any_ {
description: "Multi-field pattern condition (any field matches pattern)"
"$ref": "#/definitions/multi_field_pattern_data"
}
}
}
response {
type: object
properties {
tasks {
description: "List of report tasks"
type: array
items { "$ref": "#/definitions/report" }
}
scroll_id {
type: string
description: "Scroll ID that can be used with the next calls to get_all to retrieve more data"
}
}
}
}
"2.26": ${get_all_ex."2.23"} {
request.properties.include_subprojects {
description: "If set to 'true' and project field is set then reports from the subprojects are searched too"
type: boolean
default: false
}
}
"2.27": ${get_all_ex."2.26"} {
request.properties {
filters {
type: object
additionalProperties: ${_definitions.field_filter}
}
}
}
}
get_tags {
"2.23" {
description: "Get all the user tags used for the company reports"
request {
type: object
additionalProperties: false
}
response {
type: object
properties {
tags {
description: "The list of unique tag values"
type: array
items {type: string}
}
}
}
}
}

View File

@@ -25,7 +25,7 @@ _references {
}
}
_definitions {
include "_common.conf"
include "_tasks_common.conf"
change_many_request: ${_definitions.batch_operation} {
request {
properties {
@@ -69,374 +69,6 @@ _definitions {
}
}
}
multi_field_pattern_data {
type: object
properties {
pattern {
description: "Pattern string (regex)"
type: string
}
fields {
description: "List of field names"
type: array
items { type: string }
}
}
}
model_type_enum {
type: string
enum: ["input", "output"]
}
task_model_item {
type: object
required: [ name, model]
properties {
name {
description: "The task model name"
type: string
}
model {
description: "The model ID"
type: string
}
}
}
script {
type: object
properties {
binary {
description: "Binary to use when running the script"
type: string
default: python
}
repository {
description: "Name of the repository where the script is located"
type: string
}
tag {
description: "Repository tag"
type: string
}
branch {
description: "Repository branch id If not provided and tag not provided, default repository branch is used."
type: string
}
version_num {
description: "Version (changeset) number. Optional (default is head version) Unused if tag is provided."
type: string
}
entry_point {
description: "Path to execute within the repository"
type: string
}
working_dir {
description: "Path to the folder from which to run the script Default - root folder of repository"
type: string
}
requirements {
description: "A JSON object containing requirements strings by key"
type: object
}
diff {
description: "Uncommitted changes found in the repository when task was run"
type: string
}
}
}
output {
type: object
properties {
destination {
description: "Storage id. This is where output files will be stored."
type: string
}
model {
description: "Model id."
type: string
}
result {
description: "Task result. Values: 'success', 'failure'"
type: string
}
error {
description: "Last error text"
type: string
}
}
}
task_execution_progress_enum {
type: string
enum: [
unknown
running
stopping
stopped
]
}
output_rois_enum {
type: string
enum: [
all_in_frame
only_filtered
frame_per_roi
]
}
artifact_type_data {
type: object
properties {
preview {
description: "Description or textual data"
type: string
}
content_type {
description: "System defined raw data content type"
type: string
}
data_hash {
description: "Hash of raw data, without any headers or descriptive parts"
type: string
}
}
}
artifact_mode_enum {
type: string
enum: [
input
output
]
default: output
}
artifact {
type: object
required: [key, type]
properties {
key {
description: "Entry key"
type: string
}
type {
description: "System defined type"
type: string
}
mode {
description: "System defined input/output indication"
"$ref": "#/definitions/artifact_mode_enum"
}
uri {
description: "Raw data location"
type: string
}
content_size {
description: "Raw data length in bytes"
type: integer
}
hash {
description: "Hash of entire raw data"
type: string
}
timestamp {
description: "Epoch time when artifact was created"
type: integer
}
type_data {
description: "Additional fields defined by the system"
"$ref": "#/definitions/artifact_type_data"
}
display_data {
description: "User-defined list of key/value pairs, sorted"
type: array
items {
type: array
items {
type: string # can also be a number... TODO: upgrade the generator
}
}
}
}
}
artifact_id {
type: object
required: [key]
properties {
key {
description: "Entry key"
type: string
}
mode {
description: "System defined input/output indication"
"$ref": "#/definitions/artifact_mode_enum"
}
}
}
task_models {
type: object
properties {
input {
description: "The list of task input models"
type: array
items {"$ref": "#/definitions/task_model_item"}
}
output {
description: "The list of task output models"
type: array
items {"$ref": "#/definitions/task_model_item"}
}
}
}
execution {
type: object
properties {
queue {
description: "Queue ID where task was queued."
type: string
}
parameters {
description: "Json object containing the Task parameters"
type: object
additionalProperties: true
}
model {
description: "Execution input model ID Not applicable for Register (Import) tasks"
type: string
}
model_desc {
description: "Json object representing the Model descriptors"
type: object
additionalProperties: true
}
model_labels {
description: """Json object representing the ids of the labels in the model.
The keys are the layers' names and the values are the IDs.
Not applicable for Register (Import) tasks.
Mandatory for Training tasks"""
type: object
additionalProperties: { type: integer }
}
framework {
description: """Framework related to the task. Case insensitive. Mandatory for Training tasks. """
type: string
}
docker_cmd {
description: "Command for running docker script for the execution of the task"
type: string
}
artifacts {
description: "Task artifacts"
type: array
items { "$ref": "#/definitions/artifact" }
}
}
}
task_status_enum {
type: string
enum: [
created
queued
in_progress
stopped
published
publishing
closed
failed
completed
unknown
]
}
task_type_enum {
type: string
enum: [
training
testing
inference
data_processing
application
monitor
controller
optimizer
service
qc
custom
]
}
last_metrics_event {
type: object
properties {
metric {
description: "Metric name"
type: string
}
variant {
description: "Variant name"
type: string
}
value {
description: "Last value reported"
type: number
}
min_value {
description: "Minimum value reported"
type: number
}
max_value {
description: "Maximum value reported"
type: number
}
}
}
last_metrics_variants {
type: object
description: "Last metric events, one for each variant hash"
additionalProperties {
"$ref": "#/definitions/last_metrics_event"
}
}
params_item {
type: object
properties {
section {
description: "Section that the parameter belongs to"
type: string
}
name {
description: "Name of the parameter. The combination of section and name should be unique"
type: string
}
value {
description: "Value of the parameter"
type: string
}
type {
description: "Type of the parameter. Optional"
type: string
}
description {
description: "The parameter description. Optional"
type: string
}
}
}
configuration_item {
type: object
properties {
name {
description: "Name of the parameter. Should be unique"
type: string
}
value {
description: "Value of the parameter"
type: string
}
type {
description: "Type of the parameter. Optional"
type: string
}
description {
description: "The parameter description. Optional"
type: string
}
}
}
param_key {
type: object
properties {
@@ -450,13 +82,6 @@ _definitions {
}
}
}
section_params {
description: "Task section params"
type: object
additionalProperties {
"$ref": "#/definitions/params_item"
}
}
replace_hyperparams_enum {
type: string
enum: [
@@ -465,165 +90,6 @@ _definitions {
all
]
}
task {
type: object
properties {
id {
description: "Task id"
type: string
}
name {
description: "Task Name"
type: string
}
user {
description: "Associated user id"
type: string
}
company {
description: "Company ID"
type: string
}
type {
description: "Type of task. Values: 'training', 'testing'"
"$ref": "#/definitions/task_type_enum"
}
status {
description: ""
"$ref": "#/definitions/task_status_enum"
}
comment {
description: "Free text comment"
type: string
}
created {
description: "Task creation time (UTC) "
type: string
format: "date-time"
}
started {
description: "Task start time (UTC)"
type: string
format: "date-time"
}
completed {
description: "Task end time (UTC)"
type: string
format: "date-time"
}
active_duration {
description: "Task duration time (seconds)"
type: integer
}
parent {
description: "Parent task id"
type: string
}
project {
description: "Project ID of the project to which this task is assigned"
type: string
}
output {
description: "Task output params"
"$ref": "#/definitions/output"
}
execution {
description: "Task execution params"
"$ref": "#/definitions/execution"
}
container {
description: "Docker container parameters"
type: object
additionalProperties { type: [string, null] }
}
models {
description: "Task models"
"$ref": "#/definitions/task_models"
}
// TODO: will be removed
script {
description: "Script info"
"$ref": "#/definitions/script"
}
tags {
description: "User-defined tags list"
type: array
items { type: string }
}
system_tags {
description: "System tags list. This field is reserved for system use, please don't use it."
type: array
items { type: string }
}
status_changed {
description: "Last status change time"
type: string
format: "date-time"
}
status_message {
description: "free text string representing info about the status"
type: string
}
status_reason {
description: "Reason for last status change"
type: string
}
published {
description: "Last status change time"
type: string
format: "date-time"
}
last_worker {
description: "ID of last worker that handled the task"
type: string
}
last_worker_report {
description: "Last time a worker reported while working on this task"
type: string
format: "date-time"
}
last_update {
description: "Last time this task was created, updated, changed or events for this task were reported"
type: string
format: "date-time"
}
last_change {
description: "Last time any update was done to the task"
type: string
format: "date-time"
}
last_iteration {
description: "Last iteration reported for this task"
type: integer
}
last_metrics {
description: "Last metric variants (hash to events), one for each metric hash"
type: object
additionalProperties {
"$ref": "#/definitions/last_metrics_variants"
}
}
hyperparams {
description: "Task hyper params per section"
type: object
additionalProperties {
"$ref": "#/definitions/section_params"
}
}
configuration {
description: "Task configuration params"
type: object
additionalProperties {
"$ref": "#/definitions/configuration_item"
}
}
runtime {
description: "Task runtime mapping"
type: object
additionalProperties: true
}
}
}
task_urls {
type: object
properties {
@@ -715,6 +181,23 @@ get_all_ex {
description: "Scroll ID that can be used with the next calls to get_all_ex to retrieve more data"
}
}
"2.23": ${get_all_ex."2.15"} {
request.properties {
allow_public {
description: "Allow public tasks to be returned in the results"
type: boolean
default: true
}
}
}
"2.27": ${get_all_ex."2.23"} {
request.properties {
filters {
type: object
additionalProperties: ${_definitions.field_filter}
}
}
}
}
get_all {
"2.1" {
@@ -814,9 +297,6 @@ get_all {
"$ref": "#/definitions/multi_field_pattern_data"
}
}
dependencies {
page: [ page_size ]
}
}
response {
type: object
@@ -859,6 +339,17 @@ get_all {
description: "Scroll ID that can be used with the next calls to get_all to retrieve more data"
}
}
"2.26": ${get_all."2.15"} {
request {
properties {
include_subprojects {
description: "If set to 'true' and project field is set then tasks from the subprojects are searched too"
type: boolean
default: false
}
}
}
}
}
get_types {
"2.8" {
@@ -995,7 +486,7 @@ clone {
new_task_container {
description: "The docker container properties for the new task. If not provided then taken from the original task"
type: object
additionalProperties { type: [string, null] }
additionalProperties { type: string }
}
}
}
@@ -1173,7 +664,7 @@ create {
container {
description: "Docker container parameters"
type: object
additionalProperties { type: [string, null] }
additionalProperties { type: string }
}
}
}
@@ -1227,6 +718,10 @@ validate {
description: "Task execution params"
"$ref": "#/definitions/execution"
}
script {
description: "Script info"
"$ref": "#/definitions/script"
}
hyperparams {
description: "Task hyper params per section"
type: object
@@ -1241,10 +736,6 @@ validate {
"$ref": "#/definitions/configuration_item"
}
}
script {
description: "Script info"
"$ref": "#/definitions/script"
}
}
}
response {
@@ -1262,7 +753,7 @@ validate {
container {
description: "Docker container parameters"
type: object
additionalProperties { type: [string, null] }
additionalProperties { type: string }
}
}
}
@@ -1392,6 +883,10 @@ edit {
description: "Task execution params"
"$ref": "#/definitions/execution"
}
script {
description: "Script info"
"$ref": "#/definitions/script"
}
hyperparams {
description: "Task hyper params per section"
type: object
@@ -1406,10 +901,6 @@ edit {
"$ref": "#/definitions/configuration_item"
}
}
script {
description: "Script info"
"$ref": "#/definitions/script"
}
}
}
response: ${_definitions.update_response}
@@ -1424,7 +915,7 @@ edit {
container {
description: "Docker container parameters"
type: object
additionalProperties { type: [string, null] }
additionalProperties { type: string }
}
runtime {
description: "Task runtime mapping"
@@ -1478,6 +969,10 @@ reset {
description: "If set to 'true' then return the urls of the files that were uploaded by this task. Default value is 'false'"
type: boolean
}
delete_output_models {
description: "If set to 'true' then delete output models of this task that are not referenced by other tasks. Default value is 'true'"
type: boolean
}
}
}
response {
@@ -1665,6 +1160,10 @@ delete {
description: "If set to 'true' then return the urls of the files that were uploaded by this task. Default value is 'false'"
type: boolean
}
delete_output_models {
description: "If set to 'true' then delete output models of this task that are not referenced by other tasks. Default value is 'true'"
type: boolean
}
}
}
response {
@@ -1736,12 +1235,12 @@ archive_many {
type: string
}
}
response {
properties {
succeeded.items.properties.archived {
description: "Indicates whether the task was archived"
type: boolean
}
}
response {
properties {
succeeded.items.properties.archived {
description: "Indicates whether the task was archived"
type: boolean
}
}
}
@@ -2024,6 +1523,19 @@ dequeue {
}
}
}
"2.25": ${dequeue."1.5"} {
request.properties.remove_from_all_queues {
type: boolean
description: If set to 'true' then the task is searched and removed from all the queues. Otherwise only from the queue stored in the task execution parameters
default: false
}
}
"2.26": ${dequeue."2.25"} {
request.properties.new_status {
type: string
description: The new status to assign to the task after the dequeue instead of the default one
}
}
}
dequeue_many {
"2.13": ${_definitions.change_many_request} {
@@ -2042,6 +1554,19 @@ dequeue_many {
}
}
}
"2.25": ${dequeue_many."2.13"} {
request.properties.remove_from_all_queues {
type: boolean
description: If set to 'true' then the tasks are searched and removed from all the queues. Otherwise only from the queue stored in the task execution parameters
default: false
}
}
"2.26": ${dequeue_many."2.25"} {
request.properties.new_status {
type: string
description: The new status to assign to the task after the dequeue instead of the default one
}
}
}
set_requirements {
"2.1" {
@@ -2515,7 +2040,7 @@ move {
items { type: string }
}
project {
description: "Target project ID. If not provided, `project_name` must be provided."
description: "Target project ID. If not provided, `project_name` must be provided. Use null for the root project"
type: string
}
project_name {
@@ -2530,3 +2055,37 @@ move {
}
}
}
update_tags {
"2.27" {
description: Add or remove tags from multiple tasks
request {
type: object
properties {
ids {
type: array
description: IDs of the tasks to update
items {type: string}
}
add_tags {
type: array
description: User tags to add
items {type: string}
}
remove_tags {
type: array
description: User tags to remove
items {type: string}
}
}
}
response {
type: object
properties {
updated {
type: integer
description: The number of updated tasks
}
}
}
}
}

View File

@@ -155,6 +155,17 @@ get_current_user {
}
}
}
"2.26": ${get_current_user."2.20"} {
response.properties.settings {
type: object
properties {
max_download_items {
type: string
description: The maximum items downloaded for this user in csv file downloads
}
}
}
}
}
get_all_ex {

View File

@@ -311,6 +311,41 @@ get_all {
}
}
}
get_count {
"2.26": {
description: "Returns the number of registered workers."
request {
type: object
properties {
last_seen {
description: """Filter out workers not active for more than last_seen seconds.
A value or 0 or 'none' will disable the filter."""
type: integer
default: 0
}
tags {
description: The list of allowed worker tags. Prepend tag value with '-' in order to exclude
type: array
items { type: string }
}
system_tags {
description: The list of allowed worker system tags. Prepend tag value with '-' in order to exclude
type: array
items { type: string }
}
}
}
response {
type: object
properties {
count {
description: Workers count
type: integer
}
}
}
}
}
register {
"2.4" {
description: "Register a worker in the system. Called by the Worker Daemon."

View File

@@ -46,7 +46,6 @@ class AppSequence:
self._attach_request_handlers(request_handlers)
def _attach_request_handlers(self, request_handlers: RequestHandlers):
self.app.before_first_request(request_handlers.before_app_first_request)
self.app.before_request(request_handlers.before_request)
self.app.after_request(request_handlers.after_request)

View File

@@ -1,3 +1,5 @@
import unicodedata
import urllib.parse
from functools import partial
from flask import request, Response, redirect
@@ -20,9 +22,6 @@ class RequestHandlers:
_request_strip_prefix = config.get("apiserver.request.strip_prefix", None)
_server_header = config.get("apiserver.response.headers.server", "clearml")
def before_app_first_request(self):
pass
def before_request(self):
if request.method == "OPTIONS":
return "", 200
@@ -44,9 +43,17 @@ class RequestHandlers:
else:
headers = None
if call.result.filename:
headers = {
"Content-Disposition": f"attachment; filename={call.result.filename}"
}
try:
call.result.filename.encode("ascii")
except UnicodeEncodeError:
simple = unicodedata.normalize("NFKD", call.result.filename)
simple = simple.encode("ascii", "ignore").decode("ascii")
# safe = RFC 5987 attr-char
quoted = urllib.parse.quote(call.result.filename, safe="")
filenames = f"filename={simple}; filename*=UTF-8''{quoted}"
else:
filenames = f"filename={call.result.filename}"
headers = {"Content-Disposition": "attachment; " + filenames}
response = Response(
content,

View File

@@ -655,7 +655,11 @@ class APICall(DataContainer):
}
if self.content_type.lower() == JSON_CONTENT_TYPE:
try:
func = json.dumps if self._json_flags.pop("ensure_ascii", True) else json.dumps_notascii
func = (
json.dumps
if self._json_flags.pop("ensure_ascii", True)
else json.dumps_notascii
)
res = func(res, **(self._json_flags or {}))
except Exception as ex:
# JSON serialization may fail, probably problem with data or error_data so pop it and try again
@@ -685,8 +689,12 @@ class APICall(DataContainer):
cookies=self._result.cookies,
)
def get_redacted_headers(self):
headers = self.headers.copy()
def get_redacted_headers(self, fields=None):
headers = (
{k: v for k, v in self._headers.items() if k in fields}
if fields
else self.headers
)
if not self.requires_authorization or self.auth:
# We won't log the authorization header if call shouldn't be authorized, or if it was successfully
# authorized. This means we'll only log authorization header for calls that failed to authorize (hopefully

View File

@@ -30,24 +30,35 @@ def get_auth_func(auth_type):
raise errors.unauthorized.BadAuthType()
def authorize_token(jwt_token, *_, **__):
def authorize_token(jwt_token, service, action, call):
"""Validate token against service/endpoint and requests data (dicts).
Returns a parsed token object (auth payload)
"""
call_info = {"ip": call.real_ip}
def log_error(msg):
info = ", ".join(f"{k}={v}" for k, v in call_info.items())
log.error(f"{msg} Call info: {info}")
try:
return Token.from_encoded_token(jwt_token)
except jwt.exceptions.InvalidKeyError as ex:
log_error("Failed parsing token.")
raise errors.unauthorized.InvalidToken(
"jwt invalid key error", reason=ex.args[0]
)
except jwt.InvalidTokenError as ex:
log_error("Failed parsing token.")
raise errors.unauthorized.InvalidToken("invalid jwt token", reason=ex.args[0])
except ValueError as ex:
log.exception("Failed while processing token: %s" % ex.args[0])
log_error(f"Failed while processing token: {str(ex.args[0])}.")
raise errors.unauthorized.InvalidToken(
"failed processing token", reason=ex.args[0]
)
except Exception:
log_error("Failed processing token.")
raise
def authorize_credentials(auth_data, service, action, call):

View File

@@ -90,7 +90,7 @@ class Token(Payload):
return token
except Exception as e:
raise errors.unauthorized.InvalidToken(
"failed parsing token, %s" % e.args[0]
"failed parsing token", reason=e.args[0]
)
@classmethod

View File

@@ -39,7 +39,7 @@ class ServiceRepo(object):
"""If the check is set, parsing will fail for endpoint request with the version that is grater than the current
maximum """
_max_version = PartialVersion("2.22")
_max_version = PartialVersion("2.27")
""" Maximum version number (the highest min_version value across all endpoints) """
_endpoint_exp = (

View File

@@ -17,7 +17,7 @@ log = config.logger(__file__)
def validate_data(call: APICall, endpoint: Endpoint):
""" Perform all required call/endpoint validation, update call result appropriately """
try:
# todo: remove vaildate_required_fields once all endpoints have json schema
# todo: remove validate_required_fields once all endpoints have json schema
validate_required_fields(endpoint, call)
# set models. models will be validated automatically
@@ -50,10 +50,17 @@ def validate_role(endpoint, call):
pass
def validate_auth(endpoint, call):
""" Validate authorization for this endpoint and call.
If authentication has occurred, the call is updated with the authentication results.
def validate_auth(endpoint: Endpoint, call: "APICall"):
"""
Validate authorization for this endpoint and call.
If authentication has occurred, the call is updated with the authentication results.
For the endpoints with authorize==False the validation is not performed to improve performance
For the endpoints with authorize==True the validation should pass otherwise exception will be thrown
For the endpoints with authorize==None the validation will be tried, but it does not have to succeed
"""
if endpoint.authorize is not None and not endpoint.authorize:
return
if not call.authorization:
# No auth data. Invalid if we need to authorize and valid otherwise
if endpoint.authorize:
@@ -63,10 +70,9 @@ def validate_auth(endpoint, call):
# prepare arguments for validation
service, _, action = endpoint.name.partition(".")
# If we have auth data, we'll try to validate anyway (just so we'll have auth-based permissions whenever possible,
# even if endpoint did not require authorization)
# noinspection PyBroadException
try:
auth = call.authorization or ""
auth = call.authorization
auth_type, _, auth_data = auth.partition(" ")
authorize_func = get_auth_func(auth_type)
call.auth = authorize_func(auth_data, service, action, call)
@@ -78,7 +84,7 @@ def validate_auth(endpoint, call):
def validate_impersonation(endpoint, call):
""" Validate impersonation headers and set impersonated identity and authorization data accordingly.
:returns True if impersonating, False otherwise
:return: True if impersonating, False otherwise
"""
try:
act_as = call.act_as

View File

@@ -3,4 +3,7 @@ from apiserver.service_repo import APICall, endpoint
@endpoint("debug.ping")
def ping(call: APICall, _, __):
call.result.data = {"msg": "ClearML server"}
res = {"msg": "ClearML server"}
if call.data:
res.update(call.data)
call.result.data = res

View File

@@ -2,7 +2,7 @@ import itertools
import math
from collections import defaultdict
from operator import itemgetter
from typing import Sequence, Optional, Union, Tuple
from typing import Sequence, Optional, Union, Tuple, Mapping
import attr
import jsonmodels.fields
@@ -27,10 +27,13 @@ from apiserver.apimodels.events import (
ClearScrollRequest,
ClearTaskLogRequest,
SingleValueMetricsRequest,
GetVariantSampleRequest, GetMetricSamplesRequest,
GetVariantSampleRequest,
GetMetricSamplesRequest,
TaskMetric,
MultiTaskPlotsRequest,
)
from apiserver.bll.event import EventBLL
from apiserver.bll.event.event_common import EventType, MetricVariants
from apiserver.bll.event.event_common import EventType, MetricVariants, TaskCompanies
from apiserver.bll.event.events_iterator import Scroll
from apiserver.bll.event.scalar_key import ScalarKeyEnum, ScalarKey
from apiserver.bll.model import ModelBLL
@@ -68,9 +71,11 @@ def _assert_task_or_model_exists(
@endpoint("events.add")
def add(call: APICall, company_id, _):
data = call.data.copy()
allow_locked = data.pop("allow_locked", False)
added, err_count, err_info = event_bll.add_events(
company_id, [data], call.worker, allow_locked=allow_locked
company_id=company_id,
user_id=call.identity.user,
events=[data],
worker=call.worker,
)
call.result.data = dict(added=added, errors=err_count, errors_info=err_info)
@@ -82,10 +87,10 @@ def add_batch(call: APICall, company_id, _):
raise errors.bad_request.BatchContainsNoItems()
added, err_count, err_info = event_bll.add_events(
company_id,
events,
call.worker,
allow_locked=events[0].get("allow_locked", False),
company_id=company_id,
user_id=call.identity.user,
events=events,
worker=call.worker,
)
call.result.data = dict(added=added, errors=err_count, errors_info=err_info)
@@ -255,7 +260,9 @@ def get_vector_metrics_and_variants(call, company_id, _):
task_id = call.data["task"]
model_events = call.data["model_events"]
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=model_events,
company_id,
task_id,
model_events=model_events,
)[0]
call.result.data = dict(
metrics=event_bll.get_metrics_and_variants(
@@ -269,7 +276,9 @@ def get_scalar_metrics_and_variants(call, company_id, _):
task_id = call.data["task"]
model_events = call.data["model_events"]
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=model_events,
company_id,
task_id,
model_events=model_events,
)[0]
call.result.data = dict(
metrics=event_bll.get_metrics_and_variants(
@@ -287,7 +296,9 @@ def vector_metrics_iter_histogram(call, company_id, _):
task_id = call.data["task"]
model_events = call.data["model_events"]
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=model_events,
company_id,
task_id,
model_events=model_events,
)[0]
metric = call.data["metric"]
variant = call.data["variant"]
@@ -320,7 +331,9 @@ def make_response(
def get_task_events(_, company_id, request: TaskEventsRequest):
task_id = request.task
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=request.model_events,
company_id,
task_id,
model_events=request.model_events,
)[0]
key = ScalarKeyEnum.iter
@@ -353,7 +366,7 @@ def get_task_events(_, company_id, request: TaskEventsRequest):
total = event_bll.events_iterator.count_task_events(
event_type=request.event_type,
company_id=task_or_model.get_index_company(),
task_id=task_id,
task_ids=[task_id],
metric_variants=metric_variants,
)
@@ -398,17 +411,18 @@ def get_scalar_metric_data(call, company_id, _):
model_events = call.data.get("model_events", False)
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=model_events,
company_id,
task_id,
model_events=model_events,
)[0]
result = event_bll.get_task_events(
task_or_model.get_index_company(),
task_id,
event_type=EventType.metrics_scalar,
sort=[{"iter": {"order": "desc"}}],
metric=metric,
metrics={metric: []},
scroll_id=scroll_id,
no_scroll=no_scroll,
model_events=model_events,
)
call.result.data = dict(
@@ -457,19 +471,23 @@ def scalar_metrics_iter_histogram(
task_id=request.task,
samples=request.samples,
key=request.key,
metric_variants=_get_metric_variants_from_request(request.metrics),
)
call.result.data = metrics
def _get_task_or_model_index_company(
company_id: str, task_ids: Sequence[str], model_events=False,
) -> Tuple[str, Sequence[Task]]:
def _get_task_or_model_index_companies(
company_id: str,
task_ids: Sequence[str],
model_events=False,
) -> TaskCompanies:
"""
Verify that all tasks exists and belong to store data in the same company index
Return company and tasks
Returns lists of tasks grouped by company
"""
tasks_or_models = _assert_task_or_model_exists(
company_id, task_ids, model_events=model_events,
company_id,
task_ids,
model_events=model_events,
)
unique_ids = set(task_ids)
@@ -482,13 +500,7 @@ def _get_task_or_model_index_company(
)
raise error_cls(company=company_id, ids=invalid)
companies = {t.get_index_company() for t in tasks_or_models}
if len(companies) > 1:
raise errors.bad_request.InvalidTaskId(
"only tasks from the same company are supported"
)
return companies.pop(), tasks_or_models
return bucketize(tasks_or_models, key=lambda t: t.get_index_company())
@endpoint(
@@ -501,30 +513,44 @@ def multi_task_scalar_metrics_iter_histogram(
task_ids = request.tasks
if isinstance(task_ids, str):
task_ids = [s.strip() for s in task_ids.split(",")]
company, tasks_or_models = _get_task_or_model_index_company(
company_id, task_ids, request.model_events
)
call.result.data = dict(
metrics=event_bll.metrics.compare_scalar_metrics_average_per_iter(
company_id=company,
tasks=tasks_or_models,
companies=_get_task_or_model_index_companies(
company_id, task_ids, request.model_events
),
samples=request.samples,
key=request.key,
)
)
def _get_single_value_metrics_response(
companies: TaskCompanies, value_metrics: Mapping[str, dict]
) -> Sequence[dict]:
task_names = {
task.id: task.name for task in itertools.chain.from_iterable(companies.values())
}
return [
{"task": task_id, "task_name": task_names.get(task_id), "values": values}
for task_id, values in value_metrics.items()
]
@endpoint("events.get_task_single_value_metrics")
def get_task_single_value_metrics(
call, company_id: str, request: SingleValueMetricsRequest
):
company, tasks_or_models = _get_task_or_model_index_company(
companies = _get_task_or_model_index_companies(
company_id, request.tasks, request.model_events
)
res = event_bll.metrics.get_task_single_value_metrics(company, tasks_or_models)
call.result.data = dict(
tasks=[{"task": task, "values": values} for task, values in res.items()]
tasks=_get_single_value_metrics_response(
companies=companies,
value_metrics=event_bll.metrics.get_task_single_value_metrics(
companies=companies
),
)
)
@@ -534,22 +560,23 @@ def get_multi_task_plots_v1_7(call, company_id, _):
iters = call.data.get("iters", 1)
scroll_id = call.data.get("scroll_id")
company, tasks_or_models = _get_task_or_model_index_company(company_id, task_ids)
companies = _get_task_or_model_index_companies(company_id, task_ids)
# Get last 10K events by iteration and group them by unique metric+variant, returning top events for combination
result = event_bll.get_task_events(
company,
task_ids,
company_id=list(companies),
task_id=task_ids,
event_type=EventType.metrics_plot,
sort=[{"iter": {"order": "desc"}}],
size=10000,
scroll_id=scroll_id,
)
tasks = {t.id: t.name for t in tasks_or_models}
task_names = {
t.id: t.name for t in itertools.chain.from_iterable(companies.values())
}
return_events = _get_top_iter_unique_events_per_task(
result.events, max_iters=iters, tasks=tasks
result.events, max_iters=iters, task_names=task_names
)
call.result.data = dict(
@@ -560,40 +587,54 @@ def get_multi_task_plots_v1_7(call, company_id, _):
)
@endpoint("events.get_multi_task_plots", min_version="1.8", required_fields=["tasks"])
def get_multi_task_plots(call, company_id, _):
task_ids = call.data["tasks"]
iters = call.data.get("iters", 1)
scroll_id = call.data.get("scroll_id")
no_scroll = call.data.get("no_scroll", False)
model_events = call.data.get("model_events", False)
company, tasks_or_models = _get_task_or_model_index_company(
company_id, task_ids, model_events
)
def _get_multitask_plots(
companies: TaskCompanies,
last_iters: int,
last_iters_per_task_metric: bool,
metrics: MetricVariants = None,
scroll_id=None,
no_scroll=True,
) -> Tuple[dict, int, str]:
task_names = {
t.id: t.name for t in itertools.chain.from_iterable(companies.values())
}
result = event_bll.get_task_events(
company,
task_ids,
company_id=list(companies),
task_id=list(task_names),
event_type=EventType.metrics_plot,
metrics=metrics,
last_iter_count=last_iters,
sort=[{"iter": {"order": "desc"}}],
last_iter_count=iters,
scroll_id=scroll_id,
no_scroll=no_scroll,
model_events=model_events,
size=config.get(
"services.events.events_retrieval.multi_plots_batch_size", 1000
),
last_iters_per_task_metric=last_iters_per_task_metric,
)
tasks = {t.id: t.name for t in tasks_or_models}
return_events = _get_top_iter_unique_events_per_task(
result.events, max_iters=iters, tasks=tasks
result.events, max_iters=last_iters, task_names=task_names
)
return return_events, result.total_events, result.next_scroll_id
@endpoint("events.get_multi_task_plots", min_version="1.8")
def get_multi_task_plots(call, company_id, request: MultiTaskPlotsRequest):
companies = _get_task_or_model_index_companies(
company_id, request.tasks, model_events=request.model_events
)
return_events, total_events, next_scroll_id = _get_multitask_plots(
companies=companies,
last_iters=request.iters,
scroll_id=request.scroll_id,
no_scroll=request.no_scroll,
last_iters_per_task_metric=request.last_iters_per_task_metric,
)
call.result.data = dict(
plots=return_events,
returned=len(return_events),
total=result.total_events,
scroll_id=result.next_scroll_id,
total=total_events,
scroll_id=next_scroll_id,
)
@@ -648,20 +689,15 @@ def _get_metric_variants_from_request(
def get_task_plots(call, company_id, request: TaskPlotsRequest):
task_id = request.task
iters = request.iters
scroll_id = request.scroll_id
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=request.model_events
)[0]
result = event_bll.get_task_plots(
task_or_model.get_index_company(),
tasks=[task_id],
sort=[{"iter": {"order": "desc"}}],
task_id=task_id,
last_iterations_per_plot=iters,
scroll_id=scroll_id,
no_scroll=request.no_scroll,
metric_variants=_get_metric_variants_from_request(request.metrics),
model_events=request.model_events,
)
return_events = result.events
@@ -674,24 +710,43 @@ def get_task_plots(call, company_id, request: TaskPlotsRequest):
)
def _task_metrics_dict_from_request(req_metrics: Sequence[TaskMetric]) -> dict:
task_metrics = defaultdict(dict)
for tm in req_metrics:
task_metrics[tm.task][tm.metric] = tm.variants
for metrics in task_metrics.values():
if None in metrics:
metrics.clear()
return task_metrics
def _get_metrics_response(metric_events: Sequence[tuple]) -> Sequence[MetricEvents]:
return [
MetricEvents(
task=task,
iterations=[
IterationEvents(iter=iteration["iter"], events=iteration["events"])
for iteration in iterations
],
)
for (task, iterations) in metric_events
]
@endpoint(
"events.plots",
request_data_model=MetricEventsRequest,
response_data_model=MetricEventsResponse,
)
def task_plots(call, company_id, request: MetricEventsRequest):
task_metrics = defaultdict(dict)
for tm in request.metrics:
task_metrics[tm.task][tm.metric] = tm.variants
for metrics in task_metrics.values():
if None in metrics:
metrics.clear()
company, _ = _get_task_or_model_index_company(
company_id, task_ids=list(task_metrics), model_events=request.model_events
task_metrics = _task_metrics_dict_from_request(request.metrics)
task_ids = list(task_metrics)
task_or_models = _assert_task_or_model_exists(
company_id, task_ids=task_ids, model_events=request.model_events
)
result = event_bll.plots_iterator.get_task_events(
company_id=company,
companies={t.id: t.get_index_company() for t in task_or_models},
task_metrics=task_metrics,
iter_count=request.iters,
navigate_earlier=request.navigate_earlier,
@@ -701,16 +756,7 @@ def task_plots(call, company_id, request: MetricEventsRequest):
call.result.data_model = MetricEventsResponse(
scroll_id=result.next_scroll_id,
metrics=[
MetricEvents(
task=task,
iterations=[
IterationEvents(iter=iteration["iter"], events=iteration["events"])
for iteration in iterations
],
)
for (task, iterations) in result.metric_events
],
metrics=_get_metrics_response(result.metric_events),
)
@@ -759,7 +805,9 @@ def get_debug_images_v1_8(call, company_id, _):
model_events = call.data.get("model_events", False)
tasks_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=model_events,
company_id,
task_id,
model_events=model_events,
)[0]
result = event_bll.get_task_events(
tasks_or_model.get_index_company(),
@@ -768,7 +816,6 @@ def get_debug_images_v1_8(call, company_id, _):
sort=[{"iter": {"order": "desc"}}],
last_iter_count=iters,
scroll_id=scroll_id,
model_events=model_events,
)
return_events = result.events
@@ -789,19 +836,13 @@ def get_debug_images_v1_8(call, company_id, _):
response_data_model=MetricEventsResponse,
)
def get_debug_images(call, company_id, request: MetricEventsRequest):
task_metrics = defaultdict(dict)
for tm in request.metrics:
task_metrics[tm.task][tm.metric] = tm.variants
for metrics in task_metrics.values():
if None in metrics:
metrics.clear()
company, _ = _get_task_or_model_index_company(
company_id, task_ids=list(task_metrics), model_events=request.model_events
task_metrics = _task_metrics_dict_from_request(request.metrics)
task_ids = list(task_metrics)
task_or_models = _assert_task_or_model_exists(
company_id, task_ids=task_ids, model_events=request.model_events
)
result = event_bll.debug_images_iterator.get_task_events(
company_id=company,
companies={t.id: t.get_index_company() for t in task_or_models},
task_metrics=task_metrics,
iter_count=request.iters,
navigate_earlier=request.navigate_earlier,
@@ -811,16 +852,7 @@ def get_debug_images(call, company_id, request: MetricEventsRequest):
call.result.data_model = MetricEventsResponse(
scroll_id=result.next_scroll_id,
metrics=[
MetricEvents(
task=task,
iterations=[
IterationEvents(iter=iteration["iter"], events=iteration["events"])
for iteration in iterations
],
)
for (task, iterations) in result.metric_events
],
metrics=_get_metrics_response(result.metric_events),
)
@@ -831,7 +863,9 @@ def get_debug_images(call, company_id, request: MetricEventsRequest):
)
def get_debug_image_sample(call, company_id, request: GetVariantSampleRequest):
task_or_model = _assert_task_or_model_exists(
company_id, request.task, model_events=request.model_events,
company_id,
request.task,
model_events=request.model_events,
)[0]
res = event_bll.debug_image_sample_history.get_sample_for_variant(
company_id=task_or_model.get_index_company(),
@@ -853,7 +887,9 @@ def get_debug_image_sample(call, company_id, request: GetVariantSampleRequest):
)
def next_debug_image_sample(call, company_id, request: NextHistorySampleRequest):
task_or_model = _assert_task_or_model_exists(
company_id, request.task, model_events=request.model_events,
company_id,
request.task,
model_events=request.model_events,
)[0]
res = event_bll.debug_image_sample_history.get_next_sample(
company_id=task_or_model.get_index_company(),
@@ -866,11 +902,14 @@ def next_debug_image_sample(call, company_id, request: NextHistorySampleRequest)
@endpoint(
"events.get_plot_sample", request_data_model=GetMetricSamplesRequest,
"events.get_plot_sample",
request_data_model=GetMetricSamplesRequest,
)
def get_plot_sample(call, company_id, request: GetMetricSamplesRequest):
task_or_model = _assert_task_or_model_exists(
company_id, request.task, model_events=request.model_events,
company_id,
request.task,
model_events=request.model_events,
)[0]
res = event_bll.plot_sample_history.get_samples_for_metric(
company_id=task_or_model.get_index_company(),
@@ -885,11 +924,14 @@ def get_plot_sample(call, company_id, request: GetMetricSamplesRequest):
@endpoint(
"events.next_plot_sample", request_data_model=NextHistorySampleRequest,
"events.next_plot_sample",
request_data_model=NextHistorySampleRequest,
)
def next_plot_sample(call, company_id, request: NextHistorySampleRequest):
task_or_model = _assert_task_or_model_exists(
company_id, request.task, model_events=request.model_events,
company_id,
request.task,
model_events=request.model_events,
)[0]
res = event_bll.plot_sample_history.get_next_sample(
company_id=task_or_model.get_index_company(),
@@ -903,11 +945,15 @@ def next_plot_sample(call, company_id, request: NextHistorySampleRequest):
@endpoint("events.get_task_metrics", request_data_model=TaskMetricsRequest)
def get_task_metrics(call: APICall, company_id, request: TaskMetricsRequest):
company, _ = _get_task_or_model_index_company(
company_id, request.tasks, model_events=request.model_events
task_or_models = _assert_task_or_model_exists(
company_id,
request.tasks,
model_events=request.model_events,
)
res = event_bll.metrics.get_task_metrics(
company, task_ids=request.tasks, event_type=request.event_type
task_or_models[0].get_index_company(),
task_ids=request.tasks,
event_type=request.event_type,
)
call.result.data = {
"metrics": [{"task": task, "metrics": metrics} for (task, metrics) in res]
@@ -955,20 +1001,22 @@ def clear_task_log(call: APICall, company_id: str, request: ClearTaskLogRequest)
)
def _get_top_iter_unique_events_per_task(events, max_iters, tasks):
key = itemgetter("metric", "variant", "task", "iter")
def _get_top_iter_unique_events_per_task(
events, max_iters: int, task_names: Mapping[str, str]
):
key_fields = ("metric", "variant", "task")
unique_events = itertools.chain.from_iterable(
itertools.islice(group, max_iters)
for _, group in itertools.groupby(
sorted(events, key=key, reverse=True), key=key
sorted(events, key=itemgetter(*(key_fields + ("iter",))), reverse=True),
key=itemgetter(*key_fields),
)
)
def collect(evs, fields):
if not fields:
evs = list(evs)
return {"name": tasks.get(evs[0].get("task")), "plots": evs}
return {"name": task_names.get(evs[0].get("task")), "plots": evs}
return {
str(k): collect(group, fields[1:])
for k, group in itertools.groupby(evs, key=itemgetter(fields[0]))
@@ -1034,14 +1082,16 @@ def scalar_metrics_iter_raw(
request.batch_size = request.batch_size or scroll.request.batch_size
task_id = request.task
task_or_model = _assert_task_or_model_exists(company_id, task_id, model_events=request.model_events)[0]
task_or_model = _assert_task_or_model_exists(
company_id, task_id, model_events=request.model_events
)[0]
metric_variants = _get_metric_variants_from_request([request.metric])
if request.count_total and total is None:
total = event_bll.events_iterator.count_task_events(
event_type=EventType.metrics_scalar,
company_id=task_or_model.get_index_company(),
task_id=task_id,
task_ids=[task_id],
metric_variants=metric_variants,
)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from functools import partial
from typing import Sequence
from typing import Sequence, Union
from mongoengine import Q, EmbeddedDocument
@@ -22,6 +22,7 @@ from apiserver.apimodels.models import (
ModelsDeleteManyRequest,
ModelsGetRequest,
)
from apiserver.apimodels.tasks import UpdateTagsRequest
from apiserver.bll.model import ModelBLL, Metadata
from apiserver.bll.organization import OrgBLL, Tags
from apiserver.bll.project import ProjectBLL
@@ -59,23 +60,28 @@ org_bll = OrgBLL()
project_bll = ProjectBLL()
def conform_model_data(call: APICall, model_data: Union[Sequence[dict], dict]):
conform_output_tags(call, model_data)
unescape_metadata(call, model_data)
@endpoint("models.get_by_id", required_fields=["model"])
def get_by_id(call: APICall, company_id, _):
model_id = call.data["model"]
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
models = Model.get_many(
company=company_id,
query_dict=call.data,
query_dict=call_data,
query=Q(id=model_id),
allow_public=True,
)
if not models:
raise errors.bad_request.InvalidModelId(
"no such public or company model", id=model_id, company=company_id,
"no such public or company model",
id=model_id,
company=company_id,
)
conform_output_tags(call, models[0])
unescape_metadata(call, models[0])
conform_model_data(call, models[0])
call.result.data = {"model": models[0]}
@@ -99,35 +105,38 @@ def get_by_task_id(call: APICall, company_id, _):
).first()
if not model:
raise errors.bad_request.InvalidModelId(
"no such public or company model", id=model_id, company=company_id,
"no such public or company model",
id=model_id,
company=company_id,
)
model_dict = model.to_proper_dict()
conform_output_tags(call, model_dict)
unescape_metadata(call, model_dict)
conform_model_data(call, model_dict)
call.result.data = {"model": model_dict}
@endpoint("models.get_all_ex", request_data_model=ModelsGetRequest)
def get_all_ex(call: APICall, company_id, request: ModelsGetRequest):
conform_tag_fields(call, call.data)
process_include_subprojects(call.data)
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
process_include_subprojects(call_data)
ret_params = {}
models = Model.get_many_with_join(
company=company_id,
query_dict=call.data,
allow_public=True,
query_dict=call_data,
allow_public=request.allow_public,
ret_params=ret_params,
)
conform_output_tags(call, models)
unescape_metadata(call, models)
conform_model_data(call, models)
if not request.include_stats:
call.result.data = {"models": models, **ret_params}
return
model_ids = {model["id"] for model in models}
stats = ModelBLL.get_model_stats(company=company_id, model_ids=list(model_ids),)
stats = ModelBLL.get_model_stats(
company=company_id,
model_ids=list(model_ids),
)
for model in models:
model["stats"] = stats.get(model["id"])
@@ -138,29 +147,28 @@ def get_all_ex(call: APICall, company_id, request: ModelsGetRequest):
@endpoint("models.get_by_id_ex", required_fields=["id"])
def get_by_id_ex(call: APICall, company_id, _):
conform_tag_fields(call, call.data)
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
models = Model.get_many_with_join(
company=company_id, query_dict=call.data, allow_public=True
company=company_id, query_dict=call_data, allow_public=True
)
conform_output_tags(call, models)
unescape_metadata(call, models)
conform_model_data(call, models)
call.result.data = {"models": models}
@endpoint("models.get_all", required_fields=[])
def get_all(call: APICall, company_id, _):
conform_tag_fields(call, call.data)
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
process_include_subprojects(call_data)
ret_params = {}
models = Model.get_many(
company=company_id,
parameters=call.data,
query_dict=call.data,
parameters=call_data,
query_dict=call_data,
allow_public=True,
ret_params=ret_params,
)
conform_output_tags(call, models)
unescape_metadata(call, models)
conform_model_data(call, models)
call.result.data = {"models": models, **ret_params}
@@ -212,7 +220,7 @@ def _update_cached_tags(company: str, project: str, fields: dict):
org_bll.update_tags(
company,
Tags.Model,
project=project,
projects=[project],
tags=fields.get("tags"),
system_tags=fields.get("system_tags"),
)
@@ -220,7 +228,9 @@ def _update_cached_tags(company: str, project: str, fields: dict):
def _reset_cached_tags(company: str, projects: Sequence[str]):
org_bll.reset_tags(
company, Tags.Model, projects=projects,
company,
Tags.Model,
projects=projects,
)
@@ -283,6 +293,8 @@ def update_for_task(call: APICall, company_id, _):
id=database.utils.id(),
created=now,
last_update=now,
last_change=now,
last_changed_by=call.identity.user,
user=call.identity.user,
company=company_id,
project=task.project,
@@ -301,6 +313,7 @@ def update_for_task(call: APICall, company_id, _):
TaskBLL.update_statistics(
task_id=task_id,
company_id=company_id,
user_id=call.identity.user,
last_iteration_max=iteration,
models__output=[
ModelItem(
@@ -320,7 +333,6 @@ def update_for_task(call: APICall, company_id, _):
response_data_model=CreateModelResponse,
)
def create(call: APICall, company_id, req_model: CreateModelRequest):
if req_model.public:
company_id = ""
@@ -345,6 +357,8 @@ def create(call: APICall, company_id, req_model: CreateModelRequest):
company=company_id,
created=now,
last_update=now,
last_change=now,
last_changed_by=call.identity.user,
**fields,
)
model.save()
@@ -414,12 +428,20 @@ def edit(call: APICall, company_id, _):
task_id = model.task or fields.get("task")
if task_id and iteration is not None:
TaskBLL.update_statistics(
task_id=task_id, company_id=company_id, last_iteration_max=iteration,
task_id=task_id,
company_id=company_id,
user_id=call.identity.user,
last_iteration_max=iteration,
)
if fields:
now = datetime.utcnow()
fields.update(
last_change=now,
last_changed_by=call.identity.user,
)
if any(uf in fields for uf in last_update_fields):
fields.update(last_update=datetime.utcnow())
fields.update(last_update=now)
updated = model.update(upsert=False, **fields)
if updated:
@@ -428,8 +450,7 @@ def edit(call: APICall, company_id, _):
_reset_cached_tags(company_id, projects=[new_project, model.project])
else:
_update_cached_tags(company_id, project=model.project, fields=fields)
conform_output_tags(call, fields)
unescape_metadata(call, fields)
conform_model_data(call, fields)
call.result.data_model = UpdateResponse(updated=updated, fields=fields)
else:
call.result.data_model = UpdateResponse(updated=0)
@@ -446,13 +467,25 @@ def _update_model(call: APICall, company_id, model_id=None):
iteration = data.get("iteration")
if task_id and iteration is not None:
TaskBLL.update_statistics(
task_id=task_id, company_id=company_id, last_iteration_max=iteration,
task_id=task_id,
company_id=company_id,
user_id=call.identity.user,
last_iteration_max=iteration,
)
updated_count, updated_fields = Model.safe_update(company_id, model.id, data)
now = datetime.utcnow()
updated_count, updated_fields = Model.safe_update(
company_id,
model.id,
data,
injected_update=dict(
last_change=now,
last_changed_by=call.identity.user,
),
)
if updated_count:
if any(uf in updated_fields for uf in last_update_fields):
model.update(upsert=False, last_update=datetime.utcnow())
model.update(upsert=False, last_update=now)
new_project = updated_fields.get("project", model.project)
if new_project != model.project:
@@ -461,8 +494,7 @@ def _update_model(call: APICall, company_id, model_id=None):
_update_cached_tags(
company_id, project=model.project, fields=updated_fields
)
conform_output_tags(call, updated_fields)
unescape_metadata(call, updated_fields)
conform_model_data(call, updated_fields)
return UpdateResponse(updated=updated_count, fields=updated_fields)
@@ -482,6 +514,7 @@ def set_ready(call: APICall, company_id: str, request: PublishModelRequest):
updated, published_task = ModelBLL.publish_model(
model_id=request.model,
company_id=company_id,
user_id=call.identity.user,
force_publish_task=request.force_publish_task,
publish_task_func=publish_task if request.publish_task else None,
)
@@ -500,6 +533,7 @@ def publish_many(call: APICall, company_id, request: ModelsPublishManyRequest):
func=partial(
ModelBLL.publish_model,
company_id=company_id,
user_id=call.identity.user,
force_publish_task=request.force_publish_task,
publish_task_func=publish_task if request.publish_task else None,
),
@@ -522,7 +556,11 @@ def publish_many(call: APICall, company_id, request: ModelsPublishManyRequest):
@endpoint("models.delete", request_data_model=DeleteModelRequest)
def delete(call: APICall, company_id, request: DeleteModelRequest):
del_count, model = ModelBLL.delete_model(
model_id=request.model, company_id=company_id, force=request.force
model_id=request.model,
company_id=company_id,
user_id=call.identity.user,
force=request.force,
delete_external_artifacts=request.delete_external_artifacts,
)
if del_count:
_reset_cached_tags(
@@ -539,7 +577,13 @@ def delete(call: APICall, company_id, request: DeleteModelRequest):
)
def delete(call: APICall, company_id, request: ModelsDeleteManyRequest):
results, failures = run_batch_operation(
func=partial(ModelBLL.delete_model, company_id=company_id, force=request.force),
func=partial(
ModelBLL.delete_model,
company_id=company_id,
user_id=call.identity.user,
force=request.force,
delete_external_artifacts=request.delete_external_artifacts,
),
ids=request.ids,
)
@@ -563,7 +607,10 @@ def delete(call: APICall, company_id, request: ModelsDeleteManyRequest):
)
def archive_many(call: APICall, company_id, request: BatchRequest):
results, failures = run_batch_operation(
func=partial(ModelBLL.archive_model, company_id=company_id), ids=request.ids,
func=partial(
ModelBLL.archive_model, company_id=company_id, user_id=call.identity.user
),
ids=request.ids,
)
call.result.data_model = BatchResponse(
succeeded=[dict(id=_id, archived=bool(archived)) for _id, archived in results],
@@ -578,7 +625,8 @@ def archive_many(call: APICall, company_id, request: BatchRequest):
)
def unarchive_many(call: APICall, company_id, request: BatchRequest):
results, failures = run_batch_operation(
func=partial(ModelBLL.unarchive_model, company_id=company_id), ids=request.ids,
func=partial(ModelBLL.unarchive_model, company_id=company_id, user_id=call.identity.user),
ids=request.ids,
)
call.result.data_model = BatchResponse(
succeeded=[
@@ -591,7 +639,11 @@ def unarchive_many(call: APICall, company_id, request: BatchRequest):
@endpoint("models.make_public", min_version="2.9", request_data_model=MakePublicRequest)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Model.set_public(
company_id, ids=request.ids, invalid_cls=InvalidModelId, enabled=True
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidModelId,
enabled=True,
)
@@ -600,13 +652,17 @@ def make_public(call: APICall, company_id, request: MakePublicRequest):
)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Model.set_public(
company_id, request.ids, invalid_cls=InvalidModelId, enabled=False
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidModelId,
enabled=False,
)
@endpoint("models.move", request_data_model=MoveRequest)
def move(call: APICall, company_id: str, request: MoveRequest):
if not (request.project or request.project_name):
if not ("project" in call.data or request.project_name):
raise errors.bad_request.MissingRequiredFields(
"project or project_name is required"
)
@@ -623,30 +679,51 @@ def move(call: APICall, company_id: str, request: MoveRequest):
}
@endpoint("models.update_tags")
def update_tags(_, company_id: str, request: UpdateTagsRequest):
return {
"updated": org_bll.edit_entity_tags(
company_id=company_id,
entity_cls=Model,
entity_ids=request.ids,
add_tags=request.add_tags,
remove_tags=request.remove_tags,
)
}
@endpoint("models.add_or_update_metadata", min_version="2.13")
def add_or_update_metadata(
_: APICall, company_id: str, request: AddOrUpdateMetadataRequest
call: APICall, company_id: str, request: AddOrUpdateMetadataRequest
):
model_id = request.model
model = ModelBLL.get_company_model_by_id(company_id=company_id, model_id=model_id)
now = datetime.utcnow()
return {
"updated": Metadata.edit_metadata(
model,
items=request.metadata,
replace_metadata=request.replace_metadata,
last_update=datetime.utcnow(),
last_update=now,
last_change=now,
last_changed_by=call.identity.user,
)
}
@endpoint("models.delete_metadata", min_version="2.13")
def delete_metadata(_: APICall, company_id: str, request: DeleteMetadataRequest):
def delete_metadata(call: APICall, company_id: str, request: DeleteMetadataRequest):
model_id = request.model
model = ModelBLL.get_company_model_by_id(
company_id=company_id, model_id=model_id, only_fields=("id",)
)
now = datetime.utcnow()
return {
"updated": Metadata.delete_metadata(
model, keys=request.keys, last_update=datetime.utcnow()
model,
keys=request.keys,
last_update=now,
last_change=now,
last_changed_by=call.identity.user,
)
}

View File

@@ -1,21 +1,45 @@
import csv
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from io import StringIO
from operator import itemgetter
from typing import Mapping, Type
from typing import Mapping, Type, Sequence, Optional, Callable, Hashable
from flask import stream_with_context
from mongoengine import Q
from apiserver.apimodels.organization import TagsRequest, EntitiesCountRequest
from apiserver.apierrors import errors
from apiserver.apimodels.organization import (
TagsRequest,
EntitiesCountRequest,
DownloadForGetAllRequest,
EntityType,
PrepareDownloadForGetAllRequest,
)
from apiserver.bll.model import Metadata
from apiserver.bll.organization import OrgBLL, Tags
from apiserver.bll.project import ProjectBLL
from apiserver.config_repo import config
from apiserver.database.model import User, AttributedDocument, EntityVisibility
from apiserver.database.model.model import Model
from apiserver.database.model.project import Project
from apiserver.database.model.task.task import Task
from apiserver.database.model.task.task import Task, TaskType
from apiserver.redis_manager import redman
from apiserver.service_repo import endpoint, APICall
from apiserver.services.models import conform_model_data
from apiserver.services.tasks import (
escape_execution_parameters,
_hidden_query,
conform_task_data,
)
from apiserver.services.utils import get_tags_filter_dictionary, sort_tags_response
from apiserver.utilities import json
from apiserver.utilities.dicts import nested_get
org_bll = OrgBLL()
project_bll = ProjectBLL()
redis = redman.connection("apiserver")
conf = config.get("services.organization")
@endpoint("organization.get_tags", request_data_model=TagsRequest)
@@ -24,7 +48,10 @@ def get_tags(call: APICall, company, request: TagsRequest):
ret = defaultdict(set)
for entity in Tags.Model, Tags.Task:
tags = org_bll.get_tags(
company, entity, include_system=request.include_system, filter_=filter_dict,
company,
entity,
include_system=request.include_system,
filter_=filter_dict,
)
for field, vals in tags.items():
ret[field] |= vals
@@ -59,6 +86,7 @@ def get_entities_count(call: APICall, company, request: EntitiesCountRequest):
"models": Model,
"pipelines": Project,
"datasets": Project,
"reports": Task,
}
ret = {}
for field, entity_cls in entity_classes.items():
@@ -66,16 +94,20 @@ def get_entities_count(call: APICall, company, request: EntitiesCountRequest):
if data is None:
continue
if field == "reports":
data["type"] = TaskType.report
data["include_subprojects"] = True
if request.active_users:
if entity_cls is Project:
requested_ids = data.get("id")
if isinstance(requested_ids, str):
requested_ids = [requested_ids]
ids, _ = project_bll.get_projects_with_active_user(
ids, _ = project_bll.get_projects_with_selected_children(
company=company,
users=request.active_users,
project_ids=requested_ids,
allow_public=True,
allow_public=request.allow_public,
)
if not ids:
ret[field] = 0
@@ -85,11 +117,218 @@ def get_entities_count(call: APICall, company, request: EntitiesCountRequest):
data["user"] = request.active_users
query = Q()
if entity_cls in (Project, Task) and not request.search_hidden:
if (
entity_cls in (Project, Task)
and field not in ("reports", "pipelines", "datasets")
and not request.search_hidden
):
query &= Q(system_tags__ne=EntityVisibility.hidden.value)
ret[field] = entity_cls.get_count(
company=company, query_dict=data, query=query, allow_public=True,
company=company,
query_dict=data,
query=query,
allow_public=request.allow_public,
)
call.result.data = ret
def _get_download_getter_fn(
company: str,
call: APICall,
call_data: dict,
allow_public: bool,
entity_type: EntityType,
) -> Optional[Callable[[int, int], Sequence[dict]]]:
def get_task_data() -> Sequence[dict]:
tasks = Task.get_many_with_join(
company=company,
query_dict=call_data,
query=_hidden_query(call_data),
allow_public=allow_public,
)
conform_task_data(call, tasks)
return tasks
def get_model_data() -> Sequence[dict]:
models = Model.get_many_with_join(
company=company,
query_dict=call_data,
allow_public=allow_public,
)
conform_model_data(call, models)
return models
if entity_type == EntityType.task:
call_data = escape_execution_parameters(call_data)
get_fn = get_task_data
elif entity_type == EntityType.model:
call_data = Metadata.escape_query_parameters(call_data)
get_fn = get_model_data
else:
raise errors.bad_request.ValidationError(
f"Unsupported entity type: {str(entity_type)}"
)
def getter(page: int, page_size: int) -> Sequence[dict]:
call_data.pop("scroll_id", None)
call_data.pop("start", None)
call_data.pop("size", None)
call_data.pop("refresh_scroll", None)
call_data["page"] = page
call_data["page_size"] = page_size
return get_fn()
return getter
@endpoint("organization.prepare_download_for_get_all")
def prepare_download_for_get_all(
call: APICall, company: str, request: PrepareDownloadForGetAllRequest
):
# validate input params
field_names = set()
for fm in request.field_mappings:
name = fm.name or fm.field
if name in field_names:
raise errors.bad_request.ValidationError(
f"Field_name appears more than once in field_mappings: {str(name)}"
)
field_names.add(name)
if fm.values:
value_keys = set()
for v in fm.values:
if v.key in value_keys:
raise errors.bad_request.ValidationError(
f"Value key appears more than once in field_mappings: {str(v.key)}"
)
value_keys.add(v.key)
getter = _get_download_getter_fn(
company,
call,
call_data=call.data.copy(),
allow_public=request.allow_public,
entity_type=request.entity_type,
)
# retrieve one element just to make sure that there are no issues with the call parameters
if getter:
getter(0, 1)
redis.setex(
f"get_all_download_{call.id}",
int(conf.get("download.redis_timeout_sec", 300)),
json.dumps(call.data),
)
call.result.data = dict(prepare_id=call.id)
@endpoint("organization.download_for_get_all")
def download_for_get_all(call: APICall, company, request: DownloadForGetAllRequest):
request_data = redis.get(f"get_all_download_{request.prepare_id}")
if not request_data:
raise errors.bad_request.InvalidId(
f"prepare ID not found", prepare_id=request.prepare_id
)
try:
call_data = json.loads(request_data)
request = PrepareDownloadForGetAllRequest(**call_data)
except Exception as ex:
raise errors.server_error.DataError("failed parsing prepared data", ex=ex)
class SingleLine:
@staticmethod
def write(line: str) -> str:
return line
def generate():
field_mappings = {
mapping.get("name", mapping["field"]): {
"field_path": mapping["field"].split("."),
"values": {
v.get("key"): v.get("value")
for v in (mapping.get("values") or [])
},
}
for mapping in call_data.get("field_mappings", [])
}
get_fn = _get_download_getter_fn(
company,
call,
call_data=call_data,
allow_public=request.allow_public,
entity_type=request.entity_type,
)
if not get_fn:
yield csv.writer(SingleLine()).writerow(field_mappings)
return
def get_entity_field_as_str(
data: dict, field_path: Sequence[str], values: Mapping
) -> str:
val = nested_get(data, field_path, "")
if isinstance(val, dict):
val = val.get("id", "")
if values and isinstance(val, Hashable):
val = values.get(val, val)
return str(val)
def get_projected_fields(data: dict) -> Sequence[str]:
return [
get_entity_field_as_str(
data, field_path=m["field_path"], values=m["values"]
)
for m in field_mappings.values()
]
with ThreadPoolExecutor(1) as pool:
page = 0
page_size = int(conf.get("download.batch_size", 500))
items_left = int(conf.get("download.max_download_items", 1000))
future = pool.submit(get_fn, page, min(page_size, items_left))
while items_left > 0:
result = future.result()
if not result:
break
items_left -= len(result)
page += 1
if items_left > 0:
future = pool.submit(get_fn, page, min(page_size, items_left))
with StringIO() as fp:
writer = csv.writer(fp)
if page == 1:
fp.write("\ufeff") # utf-8 signature
writer.writerow(field_mappings)
writer.writerows(get_projected_fields(r) for r in result)
yield fp.getvalue()
if page == 0:
yield csv.writer(SingleLine()).writerow(field_mappings)
def get_project_name() -> Optional[str]:
projects = call_data.get("project")
if not projects or not isinstance(projects, (list, str)):
return
if isinstance(projects, list):
if len(projects) > 1:
return
projects = projects[0]
if projects is None:
return "root"
project: Project = Project.objects(id=projects).only("basename").first()
if not project:
return
return project.basename[: conf.get("download.max_project_name_length", 60)]
call.result.filename = "-".join(
filter(None, ("clearml", get_project_name(), f"{request.entity_type}s.csv"))
)
call.result.content_type = "text/csv"
call.result.raw_data = stream_with_context(generate())

View File

@@ -1,12 +1,21 @@
import re
from functools import partial
from apiserver.apimodels.pipelines import StartPipelineResponse, StartPipelineRequest
import attr
from apiserver.apierrors.errors.bad_request import CannotRemoveAllRuns
from apiserver.apimodels.pipelines import (
StartPipelineResponse,
StartPipelineRequest,
DeleteRunsRequest,
)
from apiserver.bll.organization import OrgBLL
from apiserver.bll.project import ProjectBLL
from apiserver.bll.task import TaskBLL
from apiserver.bll.task.task_operations import enqueue_task
from apiserver.bll.task.task_operations import enqueue_task, delete_task
from apiserver.bll.util import run_batch_operation
from apiserver.database.model.project import Project
from apiserver.database.model.task.task import Task
from apiserver.database.model.task.task import Task, TaskType
from apiserver.service_repo import APICall, endpoint
org_bll = OrgBLL()
@@ -31,6 +40,45 @@ def _update_task_name(task: Task):
task.update(name=new_name)
@endpoint("pipelines.delete_runs")
def delete_runs(call: APICall, company_id: str, request: DeleteRunsRequest):
existing_runs = set(
Task.objects(project=request.project, type=TaskType.controller).scalar("id")
)
if not existing_runs.difference(request.ids):
raise CannotRemoveAllRuns(project=request.project)
# make sure that only controller tasks are deleted
ids = existing_runs.intersection(request.ids)
if not ids:
return dict(succeeded=[], failed=[])
results, failures = run_batch_operation(
func=partial(
delete_task,
company_id=company_id,
user_id=call.identity.user,
move_to_trash=False,
force=True,
return_file_urls=False,
delete_output_models=True,
status_message="",
status_reason="Pipeline run deleted",
delete_external_artifacts=True,
),
ids=list(ids),
)
succeeded = []
if results:
for _id, (deleted, task, cleanup_res) in results:
succeeded.append(
dict(id=_id, deleted=bool(deleted), **attr.asdict(cleanup_res))
)
call.result.data = dict(succeeded=succeeded, failed=failures)
@endpoint(
"pipelines.start_pipeline", response_data_model=StartPipelineResponse,
)
@@ -60,6 +108,7 @@ def start_pipeline(call: APICall, company_id: str, request: StartPipelineRequest
queued, res = enqueue_task(
task_id=task.id,
company_id=company_id,
user_id=call.identity.user,
queue_id=request.queue,
status_message="Starting pipeline",
status_reason="",

View File

@@ -1,4 +1,4 @@
from typing import Sequence
from typing import Sequence, Optional, Tuple
import attr
from mongoengine import Q
@@ -7,27 +7,33 @@ from apiserver.apierrors import errors
from apiserver.apierrors.errors.bad_request import InvalidProjectId
from apiserver.apimodels.base import UpdateResponse, MakePublicRequest, IdResponse
from apiserver.apimodels.projects import (
DeleteRequest,
GetParamsRequest,
ProjectTagsRequest,
ProjectTaskParentsRequest,
ProjectHyperparamValuesRequest,
ProjectsGetRequest,
DeleteRequest,
MoveRequest,
MergeRequest,
ProjectOrNoneRequest,
ProjectRequest,
ProjectModelMetadataValuesRequest,
ProjectChildrenType,
GetUniqueMetricsRequest,
ProjectUserNamesRequest,
EntityTypeEnum,
)
from apiserver.bll.organization import OrgBLL, Tags
from apiserver.bll.project import ProjectBLL, ProjectQueries
from apiserver.bll.project.project_bll import pipeline_tag, reports_tag
from apiserver.bll.project.project_cleanup import (
delete_project,
validate_project_delete,
)
from apiserver.database.errors import translate_errors_context
from apiserver.database.model import EntityVisibility
from apiserver.database.model.model import Model
from apiserver.database.model.project import Project
from apiserver.database.model.task.task import TaskType, Task
from apiserver.database.utils import (
parse_from_call,
get_company_or_none_constraint,
@@ -96,30 +102,78 @@ def _adjust_search_parameters(data: dict, shallow_search: bool):
data["parent"] = [None]
@endpoint("projects.get_all_ex", request_data_model=ProjectsGetRequest)
def _get_project_stats_filter(
request: ProjectsGetRequest,
) -> Tuple[Optional[dict], bool]:
if request.include_stats_filter or not request.children_type:
return request.include_stats_filter, request.search_hidden
if request.children_tags_filter:
stats_filter = {"tags": request.children_tags_filter}
elif request.children_tags:
stats_filter = {"tags": request.children_tags}
else:
stats_filter = {}
if request.children_type == ProjectChildrenType.pipeline:
return (
{
**stats_filter,
"system_tags": [pipeline_tag],
"type": [TaskType.controller],
},
True,
)
if request.children_type == ProjectChildrenType.report:
return (
{**stats_filter, "system_tags": [reports_tag], "type": [TaskType.report]},
True,
)
return stats_filter, request.search_hidden
@endpoint("projects.get_all_ex")
def get_all_ex(call: APICall, company_id: str, request: ProjectsGetRequest):
data = call.data
conform_tag_fields(call, data)
allow_public = not request.non_public
allow_public = (
data["allow_public"]
if "allow_public" in data
else not data["non_public"]
if "non_public" in data
else request.allow_public
)
requested_ids = data.get("id")
if isinstance(requested_ids, str):
requested_ids = [requested_ids]
_adjust_search_parameters(
data, shallow_search=request.shallow_search,
)
user_active_project_ids = None
if request.active_users:
ids, user_active_project_ids = project_bll.get_projects_with_active_user(
selected_project_ids = None
if request.active_users or request.children_type:
ids, selected_project_ids = project_bll.get_projects_with_selected_children(
company=company_id,
users=request.active_users,
project_ids=requested_ids,
allow_public=allow_public,
children_type=request.children_type,
children_tags=request.children_tags,
children_tags_filter=request.children_tags_filter,
)
if not ids:
return {"projects": []}
data["id"] = ids
ret_params = {}
remove_system_tags = False
if request.search_hidden:
only_fields = data.get("only_fields")
if isinstance(only_fields, list) and "system_tags" not in only_fields:
only_fields.append("system_tags")
remove_system_tags = True
projects: Sequence[dict] = Project.get_many_with_join(
company=company_id,
query_dict=data,
@@ -130,29 +184,61 @@ def get_all_ex(call: APICall, company_id: str, request: ProjectsGetRequest):
if not projects:
return {"projects": projects, **ret_params}
if request.search_hidden:
for p in projects:
system_tags = (
p.pop("system_tags", [])
if remove_system_tags
else p.get("system_tags", [])
)
if EntityVisibility.hidden.value in system_tags:
p["hidden"] = True
conform_output_tags(call, projects)
project_ids = list({project["id"] for project in projects})
stats_filter, stats_search_hidden = _get_project_stats_filter(request)
if request.check_own_contents:
contents = project_bll.calc_own_contents(
company=company_id,
project_ids=project_ids,
filter_=request.include_stats_filter,
users=request.active_users,
)
if request.children_type == ProjectChildrenType.dataset:
contents = project_bll.calc_own_datasets(
company=company_id,
project_ids=project_ids,
filter_=stats_filter,
users=request.active_users,
)
else:
contents = project_bll.calc_own_contents(
company=company_id,
project_ids=project_ids,
filter_=stats_filter,
specific_state=request.stats_for_state,
users=request.active_users,
)
for project in projects:
project.update(**contents.get(project["id"], {}))
conform_output_tags(call, projects)
if request.include_stats:
stats, children = project_bll.get_project_stats(
company=company_id,
project_ids=project_ids,
specific_state=request.stats_for_state,
include_children=request.stats_with_children,
search_hidden=request.search_hidden,
filter_=request.include_stats_filter,
users=request.active_users,
user_active_project_ids=user_active_project_ids,
)
if request.children_type == ProjectChildrenType.dataset:
stats, children = project_bll.get_project_dataset_stats(
company=company_id,
project_ids=project_ids,
include_children=request.stats_with_children,
filter_=stats_filter,
users=request.active_users,
selected_project_ids=selected_project_ids,
)
else:
stats, children = project_bll.get_project_stats(
company=company_id,
project_ids=project_ids,
specific_state=request.stats_for_state,
include_children=request.stats_with_children,
search_hidden=stats_search_hidden,
filter_=stats_filter,
users=request.active_users,
selected_project_ids=selected_project_ids,
)
for project in projects:
project["stats"] = stats[project["id"]]
@@ -277,28 +363,30 @@ def delete(call: APICall, company_id: str, request: DeleteRequest):
project_id=request.project,
force=request.force,
delete_contents=request.delete_contents,
delete_external_artifacts=request.delete_external_artifacts,
)
_reset_cached_tags(company_id, projects=list(affected_projects))
call.result.data = {**attr.asdict(res)}
@endpoint(
"projects.get_unique_metric_variants", request_data_model=ProjectOrNoneRequest
"projects.get_unique_metric_variants", request_data_model=GetUniqueMetricsRequest
)
def get_unique_metric_variants(
call: APICall, company_id: str, request: ProjectOrNoneRequest
call: APICall, company_id: str, request: GetUniqueMetricsRequest
):
metrics = project_queries.get_unique_metric_variants(
company_id,
[request.project] if request.project else None,
include_subprojects=request.include_subprojects,
model_metrics=request.model_metrics,
)
call.result.data = {"metrics": metrics}
@endpoint("projects.get_model_metadata_keys",)
@endpoint("projects.get_model_metadata_keys")
def get_model_metadata_keys(call: APICall, company_id: str, request: GetParamsRequest):
total, remaining, keys = project_queries.get_model_metadata_keys(
company_id,
@@ -325,6 +413,8 @@ def get_model_metadata_values(
key=request.key,
include_subprojects=request.include_subprojects,
allow_public=request.allow_public,
page=request.page,
page_size=request.page_size,
)
call.result.data = {
"total": total,
@@ -369,6 +459,9 @@ def get_hyperparam_values(
name=request.name,
include_subprojects=request.include_subprojects,
allow_public=request.allow_public,
pattern=request.pattern,
page=request.page,
page_size=request.page_size,
)
call.result.data = {
"total": total,
@@ -420,7 +513,11 @@ def get_tags(call: APICall, company, request: ProjectTagsRequest):
)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Project.set_public(
company_id, ids=request.ids, invalid_cls=InvalidProjectId, enabled=True
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidProjectId,
enabled=True,
)
@@ -429,7 +526,11 @@ def make_public(call: APICall, company_id, request: MakePublicRequest):
)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Project.set_public(
company_id, ids=request.ids, invalid_cls=InvalidProjectId, enabled=False
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidProjectId,
enabled=False,
)
@@ -442,10 +543,23 @@ def get_task_parents(
call: APICall, company_id: str, request: ProjectTaskParentsRequest
):
call.result.data = {
"parents": project_bll.get_task_parents(
"parents": ProjectBLL.get_task_parents(
company_id,
projects=request.projects,
include_subprojects=request.include_subprojects,
state=request.tasks_state,
name=request.task_name,
)
}
@endpoint("projects.get_user_names")
def get_user_names(call: APICall, company_id: str, request: ProjectUserNamesRequest):
call.result.data = {
"users": ProjectBLL.get_entity_users(
company_id,
entity_cls=Model if request.entity == EntityTypeEnum.model else Task,
projects=request.projects,
include_subprojects=request.include_subprojects,
)
}

View File

@@ -1,3 +1,5 @@
from typing import Union, Sequence
from mongoengine import Q
from apiserver.apimodels.base import UpdateResponse
@@ -21,6 +23,7 @@ from apiserver.apimodels.queues import (
)
from apiserver.bll.model import Metadata
from apiserver.bll.queue import QueueBLL
from apiserver.bll.queue.queue_bll import MOVE_FIRST, MOVE_LAST
from apiserver.bll.workers import WorkerBLL
from apiserver.config_repo import config
from apiserver.database.model.task.task import Task
@@ -38,14 +41,18 @@ worker_bll = WorkerBLL()
queue_bll = QueueBLL(worker_bll)
def conform_queue_data(call: APICall, queue_data: Union[Sequence[dict], dict]):
conform_output_tags(call, queue_data)
unescape_metadata(call, queue_data)
@endpoint("queues.get_by_id", min_version="2.4", request_data_model=GetByIdRequest)
def get_by_id(call: APICall, company_id, request: GetByIdRequest):
queue = queue_bll.get_by_id(
company_id, request.queue, max_task_entries=request.max_task_entries
)
queue_dict = queue.to_proper_dict()
conform_output_tags(call, queue_dict)
unescape_metadata(call, queue_dict)
conform_queue_data(call, queue_dict)
call.result.data = {"queue": queue_dict}
@@ -76,16 +83,15 @@ def get_all_ex(call: APICall, company: str, request: GetAllRequest):
conform_tag_fields(call, call.data)
ret_params = {}
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
queues = queue_bll.get_queue_infos(
company_id=company,
query_dict=call.data,
query=_hidden_query(call.data),
query_dict=call_data,
query=_hidden_query(call_data),
max_task_entries=request.max_task_entries,
ret_params=ret_params,
)
conform_output_tags(call, queues)
unescape_metadata(call, queues)
conform_queue_data(call, queues)
call.result.data = {"queues": queues, **ret_params}
@@ -93,16 +99,15 @@ def get_all_ex(call: APICall, company: str, request: GetAllRequest):
def get_all(call: APICall, company: str, request: GetAllRequest):
conform_tag_fields(call, call.data)
ret_params = {}
Metadata.escape_query_parameters(call)
call_data = Metadata.escape_query_parameters(call.data)
queues = queue_bll.get_all(
company_id=company,
query_dict=call.data,
query=_hidden_query(call.data),
query_dict=call_data,
query=_hidden_query(call_data),
max_task_entries=request.max_task_entries,
ret_params=ret_params,
)
conform_output_tags(call, queues)
unescape_metadata(call, queues)
conform_queue_data(call, queues)
call.result.data = {"queues": queues, **ret_params}
@@ -134,15 +139,17 @@ def update(call: APICall, company_id, req_model: UpdateRequest):
updated, fields = queue_bll.update(
company_id=company_id, queue_id=req_model.queue, **data
)
conform_output_tags(call, fields)
unescape_metadata(call, fields)
conform_queue_data(call, fields)
call.result.data_model = UpdateResponse(updated=updated, fields=fields)
@endpoint("queues.delete", min_version="2.4", request_data_model=DeleteRequest)
def delete(call: APICall, company_id, req_model: DeleteRequest):
queue_bll.delete(
company_id=company_id, queue_id=req_model.queue, force=req_model.force
company_id=company_id,
user_id=call.identity.user,
queue_id=req_model.queue,
force=req_model.force,
)
call.result.data = {"deleted": 1}
@@ -164,7 +171,7 @@ def get_next_task(call: APICall, company_id, request: GetNextTaskRequest):
if entry:
data = {"entry": entry.to_proper_dict()}
if request.get_task_info:
task = Task.objects(id=entry.task).first()
task = Task.objects(id=entry.task).only("company", "user").first()
if task:
data["task_info"] = {"company": task.company, "user": task.user}
@@ -192,7 +199,7 @@ def move_task_forward(call: APICall, company_id, req_model: MoveTaskRequest):
company_id=company_id,
queue_id=req_model.queue,
task_id=req_model.task,
pos_func=lambda p: max(0, p - req_model.count),
move_count=-req_model.count,
)
)
@@ -209,7 +216,7 @@ def move_task_backward(call: APICall, company_id, req_model: MoveTaskRequest):
company_id=company_id,
queue_id=req_model.queue,
task_id=req_model.task,
pos_func=lambda p: max(0, p + req_model.count),
move_count=req_model.count,
)
)
@@ -226,7 +233,7 @@ def move_task_to_front(call: APICall, company_id, req_model: TaskRequest):
company_id=company_id,
queue_id=req_model.queue,
task_id=req_model.task,
pos_func=lambda p: 0,
move_count=MOVE_FIRST,
)
)
@@ -243,7 +250,7 @@ def move_task_to_back(call: APICall, company_id, req_model: TaskRequest):
company_id=company_id,
queue_id=req_model.queue,
task_id=req_model.task,
pos_func=lambda p: -1,
move_count=MOVE_LAST,
)
)

View File

@@ -0,0 +1,420 @@
import textwrap
from datetime import datetime
from itertools import chain
from typing import Sequence
from mongoengine import Q
from apiserver.apimodels.reports import (
CreateReportRequest,
UpdateReportRequest,
PublishReportRequest,
ArchiveReportRequest,
DeleteReportRequest,
MoveReportRequest,
GetTasksDataRequest,
EventsRequest,
GetAllRequest,
)
from apiserver.apierrors import errors
from apiserver.apimodels.base import UpdateResponse
from apiserver.bll.project.project_bll import reports_project_name, reports_tag
from apiserver.database.model.model import Model
from apiserver.services.models import conform_model_data
from apiserver.services.utils import process_include_subprojects, sort_tags_response
from apiserver.bll.organization import OrgBLL
from apiserver.bll.project import ProjectBLL
from apiserver.bll.task import TaskBLL, ChangeStatusRequest
from apiserver.database.model import EntityVisibility
from apiserver.database.model.project import Project
from apiserver.database.model.task.task import Task, TaskType, TaskStatus
from apiserver.service_repo import APICall, endpoint
from apiserver.services.events import (
_get_task_or_model_index_companies,
event_bll,
_get_metrics_response,
_get_metric_variants_from_request,
_get_multitask_plots,
_get_single_value_metrics_response,
)
from apiserver.services.tasks import (
escape_execution_parameters,
_hidden_query,
conform_task_data,
)
org_bll = OrgBLL()
project_bll = ProjectBLL()
task_bll = TaskBLL()
update_fields = {
"name",
"tags",
"comment",
"report",
"report_assets",
}
def _assert_report(company_id, task_id, only_fields=None, requires_write_access=True):
if only_fields and "type" not in only_fields:
only_fields += ("type",)
task = TaskBLL.get_task_with_access(
task_id=task_id,
company_id=company_id,
only=only_fields,
requires_write_access=requires_write_access,
)
if task.type != TaskType.report:
raise errors.bad_request.OperationSupportedOnReportsOnly(id=task_id)
return task
@endpoint("reports.update", response_data_model=UpdateResponse)
def update_report(call: APICall, company_id: str, request: UpdateReportRequest):
task = _assert_report(
task_id=request.task,
company_id=company_id,
only_fields=("status",),
)
partial_update_dict = {
field: value for field, value in call.data.items() if field in update_fields
}
if not partial_update_dict:
return UpdateResponse(updated=0)
allowed_for_published = set(partial_update_dict.keys()).issubset(
{"tags", "name", "comment"}
)
if task.status != TaskStatus.created and not allowed_for_published:
raise errors.bad_request.InvalidTaskStatus(
expected=TaskStatus.created, status=task.status
)
now = datetime.utcnow()
more_updates = {"last_change": now, "last_changed_by": call.identity.user}
if not allowed_for_published:
more_updates["last_update"] = now
updated = task.update(upsert=False, **partial_update_dict, **more_updates)
if not updated:
return UpdateResponse(updated=0)
updated_tags = partial_update_dict.get("tags")
if updated_tags:
partial_update_dict["tags"] = sorted(updated_tags)
updated_report = partial_update_dict.get("report")
if updated_report:
partial_update_dict["report"] = textwrap.shorten(updated_report, width=100)
return UpdateResponse(updated=updated, fields=partial_update_dict)
def _ensure_reports_project(company: str, user: str, name: str):
name = name.strip("/")
_, _, basename = name.rpartition("/")
if basename != reports_project_name:
name = f"{name}/{reports_project_name}"
return project_bll.find_or_create(
user=user,
company=company,
project_name=name,
description="Reports project",
system_tags=[reports_tag, EntityVisibility.hidden.value],
)
@endpoint("reports.create")
def create_report(call: APICall, company_id: str, request: CreateReportRequest):
user_id = call.identity.user
project_id = request.project
if request.project:
project = Project.get_for_writing(
company=company_id, id=project_id, _only=("name",)
)
project_name = project.name
else:
project_name = ""
project_id = _ensure_reports_project(
company=company_id, user=user_id, name=project_name
)
task = task_bll.create(
company=company_id,
user=user_id,
fields=dict(
project=project_id,
name=request.name,
tags=request.tags,
comment=request.comment,
type=TaskType.report,
system_tags=[reports_tag, EntityVisibility.hidden.value],
),
)
task.save()
call.result.data = {"id": task.id, "project_id": project_id}
def _delete_reports_project_if_empty(project_id):
project = Project.objects(id=project_id).only("basename").first()
if (
project
and project.basename == reports_project_name
and Task.objects(project=project_id).count() == 0
):
project.delete()
@endpoint("reports.get_all_ex")
def get_all_ex(call: APICall, company_id, request: GetAllRequest):
call_data = call.data
call_data["type"] = TaskType.report
process_include_subprojects(call_data)
# bring projects one level down in case not the .reports project was passed
if "project" in call_data:
project_ids = call_data["project"]
if not isinstance(project_ids, list):
project_ids = [project_ids]
query = Q(parent__in=project_ids) | Q(id__in=project_ids)
project_ids = Project.objects(query & Q(basename=reports_project_name)).scalar(
"id"
)
if not project_ids:
return {"tasks": []}
call_data["project"] = list(project_ids)
ret_params = {}
tasks = Task.get_many_with_join(
company=company_id,
query_dict=call_data,
allow_public=request.allow_public,
ret_params=ret_params,
)
conform_task_data(call, tasks)
call.result.data = {"tasks": tasks, **ret_params}
def _get_task_metrics_from_request(
task_ids: Sequence[str], request: EventsRequest
) -> dict:
task_metrics = {}
for task in task_ids:
task_dict = {}
for mv in request.metrics:
task_dict[mv.metric] = mv.variants
task_metrics[task] = task_dict
return task_metrics
@endpoint("reports.get_task_data")
def get_task_data(call: APICall, company_id, request: GetTasksDataRequest):
if request.model_events:
entity_cls = Model
conform_data = conform_model_data
else:
entity_cls = Task
conform_data = conform_task_data
call_data = escape_execution_parameters(call.data)
process_include_subprojects(call_data)
ret_params = {}
tasks = entity_cls.get_many_with_join(
company=company_id,
query_dict=call_data,
query=_hidden_query(call_data),
allow_public=request.allow_public,
ret_params=ret_params,
)
conform_data(call, tasks)
res = {"tasks": tasks, **ret_params}
if not (
request.debug_images
or request.plots
or request.scalar_metrics_iter_histogram
or request.single_value_metrics
):
return res
task_ids = [task["id"] for task in tasks]
companies = _get_task_or_model_index_companies(
company_id, task_ids=task_ids, model_events=request.model_events
)
if request.debug_images:
result = event_bll.debug_images_iterator.get_task_events(
companies={
t.id: t.company for t in chain.from_iterable(companies.values())
},
task_metrics=_get_task_metrics_from_request(task_ids, request.debug_images),
iter_count=request.debug_images.iters,
)
res["debug_images"] = [
r.to_struct() for r in _get_metrics_response(result.metric_events)
]
if request.plots:
res["plots"] = _get_multitask_plots(
companies=companies,
last_iters=request.plots.iters,
metrics=_get_metric_variants_from_request(request.plots.metrics),
last_iters_per_task_metric=request.plots.last_iters_per_task_metric,
)[0]
if request.scalar_metrics_iter_histogram:
res[
"scalar_metrics_iter_histogram"
] = event_bll.metrics.compare_scalar_metrics_average_per_iter(
companies=companies,
samples=request.scalar_metrics_iter_histogram.samples,
key=request.scalar_metrics_iter_histogram.key,
metric_variants=_get_metric_variants_from_request(
request.scalar_metrics_iter_histogram.metrics
),
)
if request.single_value_metrics:
res["single_value_metrics"] = _get_single_value_metrics_response(
companies=companies,
value_metrics=event_bll.metrics.get_task_single_value_metrics(
companies=companies
),
)
call.result.data = res
@endpoint("reports.move")
def move(call: APICall, company_id: str, request: MoveReportRequest):
if not ("project" in call.data or request.project_name):
raise errors.bad_request.MissingRequiredFields(
"project or project_name is required"
)
task = _assert_report(
company_id=company_id,
task_id=request.task,
only_fields=("project",),
)
user_id = call.identity.user
project_name = request.project_name
if not project_name:
if request.project:
project = Project.get_for_writing(
company=company_id, id=request.project, _only=("name",)
)
project_name = project.name
else:
project_name = ""
project_id = _ensure_reports_project(
company=company_id, user=user_id, name=project_name
)
project_bll.move_under_project(
entity_cls=Task,
user=call.identity.user,
company=company_id,
ids=[request.task],
project=project_id,
)
_delete_reports_project_if_empty(task.project)
return {"project_id": project_id}
@endpoint(
"reports.publish",
response_data_model=UpdateResponse,
)
def publish(call: APICall, company_id, request: PublishReportRequest):
task = _assert_report(company_id=company_id, task_id=request.task)
updates = ChangeStatusRequest(
task=task,
new_status=TaskStatus.published,
force=True,
status_reason="",
status_message=request.message,
user_id=call.identity.user,
).execute(published=datetime.utcnow())
call.result.data_model = UpdateResponse(**updates)
@endpoint("reports.archive")
def archive(call: APICall, company_id, request: ArchiveReportRequest):
task = _assert_report(company_id=company_id, task_id=request.task)
archived = task.update(
status_message=request.message,
status_reason="",
add_to_set__system_tags=EntityVisibility.archived.value,
last_change=datetime.utcnow(),
last_changed_by=call.identity.user,
)
return {"archived": archived}
@endpoint("reports.unarchive")
def unarchive(call: APICall, company_id, request: ArchiveReportRequest):
task = _assert_report(company_id=company_id, task_id=request.task)
unarchived = task.update(
status_message=request.message,
status_reason="",
pull__system_tags=EntityVisibility.archived.value,
last_change=datetime.utcnow(),
last_changed_by=call.identity.user,
)
return {"unarchived": unarchived}
# @endpoint("reports.share")
# def share(call: APICall, company_id, request: ShareReportRequest):
# _assert_report(
# company_id=company_id, user_id=call.identity.user, task_id=request.task
# )
# call.result.data = {
# "changed": task_bll.share_task(
# company_id=company_id, task_ids=[request.task], share=request.share
# )
# }
@endpoint("reports.delete")
def delete(call: APICall, company_id, request: DeleteReportRequest):
task = _assert_report(
company_id=company_id,
task_id=request.task,
only_fields=("project",),
)
if (
task.status != TaskStatus.created
and EntityVisibility.archived.value not in task.system_tags
and not request.force
):
raise errors.bad_request.TaskCannotBeDeleted(
"due to status, use force=True",
task=task.id,
expected=TaskStatus.created,
current=task.status,
)
task.delete()
_delete_reports_project_if_empty(task.project)
call.result.data = {"deleted": 1}
@endpoint("reports.get_tags")
def get_tags(call: APICall, company_id: str, _):
tags = Task.objects(company=company_id, type=TaskType.report).distinct(field="tags")
call.result.data = sort_tags_response({"tags": tags})

View File

@@ -64,6 +64,10 @@ from apiserver.apimodels.tasks import (
ResetBatchItem,
CompletedRequest,
CompletedResponse,
GetAllReq,
DequeueRequest,
DequeueManyRequest,
UpdateTagsRequest,
)
from apiserver.bll.event import EventBLL
from apiserver.bll.model import ModelBLL
@@ -73,7 +77,6 @@ from apiserver.bll.queue import QueueBLL
from apiserver.bll.task import (
TaskBLL,
ChangeStatusRequest,
update_project_time,
)
from apiserver.bll.task.artifacts import (
artifacts_prepare_for_save,
@@ -98,7 +101,7 @@ from apiserver.bll.task.task_operations import (
move_tasks_to_trash,
)
from apiserver.bll.task.utils import update_task, get_task_for_update, deleted_prefix
from apiserver.bll.util import SetFieldsResolver, run_batch_operation
from apiserver.bll.util import run_batch_operation, update_project_time
from apiserver.database.errors import translate_errors_context
from apiserver.database.model import EntityVisibility
from apiserver.database.model.task.output import Output
@@ -109,7 +112,11 @@ from apiserver.database.model.task.task import (
ModelItem,
TaskModelTypes,
)
from apiserver.database.utils import get_fields_attr, parse_from_call, get_options
from apiserver.database.utils import (
get_fields_attr,
parse_from_call,
get_options,
)
from apiserver.service_repo import APICall, endpoint
from apiserver.services.utils import (
conform_tag_fields,
@@ -136,32 +143,15 @@ project_bll = ProjectBLL()
def set_task_status_from_call(
request: UpdateRequest, company_id, new_status=None, **set_fields
request: UpdateRequest, company_id: str, user_id: str, new_status=None, **set_fields
) -> dict:
fields_resolver = SetFieldsResolver(set_fields)
task = TaskBLL.get_task_with_access(
request.task,
company_id=company_id,
only=tuple(
{"status", "project", "started", "duration"} | fields_resolver.get_names()
),
only=("id", "status", "project"),
requires_write_access=True,
)
if "duration" not in fields_resolver.get_names():
if new_status == Task.started:
fields_resolver.add_fields(min__duration=max(0, task.duration or 0))
elif new_status in (
TaskStatus.completed,
TaskStatus.failed,
TaskStatus.stopped,
):
fields_resolver.add_fields(
duration=int((task.started - datetime.utcnow()).total_seconds())
if task.started
else 0
)
status_reason = request.status_reason
status_message = request.status_message
force = request.force
@@ -171,7 +161,8 @@ def set_task_status_from_call(
status_reason=status_reason,
status_message=status_message,
force=force,
).execute(**fields_resolver.get_fields(task))
user_id=user_id,
).execute(**set_fields)
@endpoint("tasks.get_by_id", request_data_model=TaskRequest)
@@ -180,17 +171,17 @@ def get_by_id(call: APICall, company_id, req_model: TaskRequest):
req_model.task, company_id=company_id, allow_public=True
)
task_dict = task.to_proper_dict()
unprepare_from_saved(call, task_dict)
conform_task_data(call, task_dict)
call.result.data = {"task": task_dict}
def escape_execution_parameters(call: APICall) -> dict:
if not call.data:
return call.data
def escape_execution_parameters(call_data: dict) -> dict:
if not call_data:
return call_data
keys = list(call.data)
keys = list(call_data)
call_data = {
safe_key: call.data[key] for key, safe_key in zip(keys, escape_paths(keys))
safe_key: call_data[key] for key, safe_key in zip(keys, escape_paths(keys))
}
projection = Task.get_projection(call_data)
@@ -214,44 +205,40 @@ def _hidden_query(data: dict) -> Q:
return Q(system_tags__ne=EntityVisibility.hidden.value)
@endpoint("tasks.get_all_ex", required_fields=[])
def get_all_ex(call: APICall, company_id, _):
@endpoint("tasks.get_all_ex")
def get_all_ex(call: APICall, company_id, request: GetAllReq):
conform_tag_fields(call, call.data)
call_data = escape_execution_parameters(call)
call_data = escape_execution_parameters(call.data)
process_include_subprojects(call_data)
ret_params = {}
tasks = Task.get_many_with_join(
company=company_id,
query_dict=call_data,
query=_hidden_query(call_data),
allow_public=True,
allow_public=request.allow_public,
ret_params=ret_params,
)
unprepare_from_saved(call, tasks)
conform_task_data(call, tasks)
call.result.data = {"tasks": tasks, **ret_params}
@endpoint("tasks.get_by_id_ex", required_fields=["id"])
def get_by_id_ex(call: APICall, company_id, _):
conform_tag_fields(call, call.data)
call_data = escape_execution_parameters(call)
call_data = escape_execution_parameters(call.data)
tasks = Task.get_many_with_join(
company=company_id, query_dict=call_data, allow_public=True,
)
unprepare_from_saved(call, tasks)
conform_task_data(call, tasks)
call.result.data = {"tasks": tasks}
@endpoint("tasks.get_all", required_fields=[])
def get_all(call: APICall, company_id, _):
conform_tag_fields(call, call.data)
call_data = escape_execution_parameters(call)
call_data = escape_execution_parameters(call.data)
process_include_subprojects(call_data)
ret_params = {}
tasks = Task.get_many(
@@ -262,7 +249,7 @@ def get_all(call: APICall, company_id, _):
allow_public=True,
ret_params=ret_params,
)
unprepare_from_saved(call, tasks)
conform_task_data(call, tasks)
call.result.data = {"tasks": tasks, **ret_params}
@@ -291,6 +278,7 @@ def stop(call: APICall, company_id, req_model: UpdateRequest):
**stop_task(
task_id=req_model.task,
company_id=company_id,
user_id=call.identity.user,
user_name=call.identity.user_name,
status_reason=req_model.status_reason,
force=req_model.force,
@@ -308,6 +296,7 @@ def stop_many(call: APICall, company_id, request: StopManyRequest):
func=partial(
stop_task,
company_id=company_id,
user_id=call.identity.user,
user_name=call.identity.user_name,
status_reason=request.status_reason,
force=request.force,
@@ -329,7 +318,8 @@ def stopped(call: APICall, company_id, req_model: UpdateRequest):
call.result.data_model = UpdateResponse(
**set_task_status_from_call(
req_model,
company_id,
company_id=company_id,
user_id=call.identity.user,
new_status=TaskStatus.stopped,
completed=datetime.utcnow(),
)
@@ -345,7 +335,8 @@ def started(call: APICall, company_id, req_model: UpdateRequest):
res = StartedResponse(
**set_task_status_from_call(
req_model,
company_id,
company_id=company_id,
user_id=call.identity.user,
new_status=TaskStatus.in_progress,
min__started=datetime.utcnow(), # don't override a previous, smaller "started" field value
)
@@ -359,7 +350,12 @@ def started(call: APICall, company_id, req_model: UpdateRequest):
)
def failed(call: APICall, company_id, req_model: UpdateRequest):
call.result.data_model = UpdateResponse(
**set_task_status_from_call(req_model, company_id, new_status=TaskStatus.failed)
**set_task_status_from_call(
req_model,
company_id=company_id,
user_id=call.identity.user,
new_status=TaskStatus.failed,
)
)
@@ -368,7 +364,12 @@ def failed(call: APICall, company_id, req_model: UpdateRequest):
)
def close(call: APICall, company_id, req_model: UpdateRequest):
call.result.data_model = UpdateResponse(
**set_task_status_from_call(req_model, company_id, new_status=TaskStatus.closed)
**set_task_status_from_call(
req_model,
company_id=company_id,
user_id=call.identity.user,
new_status=TaskStatus.closed,
)
)
@@ -415,7 +416,7 @@ def prepare_for_save(call: APICall, fields: dict, previous_task: Task = None):
return fields
def unprepare_from_saved(call: APICall, tasks_data: Union[Sequence[dict], dict]):
def conform_task_data(call: APICall, tasks_data: Union[Sequence[dict], dict]):
if isinstance(tasks_data, dict):
tasks_data = [tasks_data]
@@ -475,7 +476,9 @@ def _validate_and_get_task_from_call(call: APICall, **kwargs) -> Tuple[Task, dic
field_does_not_exist_cls=errors.bad_request.ValidationError
):
fields = prepare_create_fields(call, **kwargs)
task = task_bll.create(call, fields)
task = task_bll.create(
company=call.identity.company, user=call.identity.user, fields=fields
)
task_bll.validate(task)
@@ -494,7 +497,7 @@ def _update_cached_tags(company: str, project: str, fields: dict):
org_bll.update_tags(
company,
Tags.Task,
project=project,
projects=[project],
tags=fields.get("tags"),
system_tags=fields.get("system_tags"),
)
@@ -578,7 +581,9 @@ def update(call: APICall, company_id, req_model: UpdateRequest):
company_id=company_id,
id=task_id,
partial_update_dict=partial_update_dict,
injected_update=dict(last_change=datetime.utcnow()),
injected_update=dict(
last_change=datetime.utcnow(), last_changed_by=call.identity.user,
),
)
if updated_count:
new_project = updated_fields.get("project", task.project)
@@ -589,7 +594,7 @@ def update(call: APICall, company_id, req_model: UpdateRequest):
company_id, project=task.project, fields=updated_fields
)
update_project_time(updated_fields.get("project"))
unprepare_from_saved(call, updated_fields)
conform_task_data(call, updated_fields)
return UpdateResponse(updated=updated_count, fields=updated_fields)
@@ -611,7 +616,11 @@ def set_requirements(call: APICall, company_id, req_model: SetRequirementsReques
raise errors.bad_request.MissingTaskFields(
"Task has no script field", task=task.id
)
res = update_task(task, update_cmds=dict(script__requirements=requirements))
res = update_task(
task,
user_id=call.identity.user,
update_cmds=dict(script__requirements=requirements),
)
call.result.data_model = UpdateResponse(updated=res)
if res:
call.result.data_model.fields = {"script.requirements": requirements}
@@ -646,7 +655,9 @@ def update_batch(call: APICall, company_id, _):
partial_update_dict = Task.get_safe_update_dict(fields)
if not partial_update_dict:
continue
partial_update_dict.update(last_change=now)
partial_update_dict.update(
last_change=now, last_changed_by=call.identity.user,
)
update_op = UpdateOne(
{"_id": id, "company": company_id}, {"$set": partial_update_dict}
)
@@ -710,7 +721,11 @@ def edit(call: APICall, company_id, req_model: UpdateRequest):
d.update(value)
fields[key] = d
task_bll.validate(task_bll.create(call, fields))
task_bll.validate(
task_bll.create(
company=call.identity.company, user=call.identity.user, fields=fields
)
)
# make sure field names do not end in mongoengine comparison operators
fixed_fields = {
@@ -719,7 +734,7 @@ def edit(call: APICall, company_id, req_model: UpdateRequest):
}
if fixed_fields:
now = datetime.utcnow()
last_change = dict(last_change=now)
last_change = dict(last_change=now, last_changed_by=call.identity.user)
if not set(fields).issubset(Task.user_set_allowed()):
last_change.update(last_update=now)
fields.update(**last_change)
@@ -734,7 +749,7 @@ def edit(call: APICall, company_id, req_model: UpdateRequest):
company_id, project=task.project, fields=fixed_fields
)
update_project_time(fields.get("project"))
unprepare_from_saved(call, fields)
conform_task_data(call, fields)
call.result.data_model = UpdateResponse(updated=updated, fields=fields)
else:
call.result.data_model = UpdateResponse(updated=0)
@@ -756,6 +771,7 @@ def edit_hyper_params(call: APICall, company_id, request: EditHyperParamsRequest
call.result.data = {
"updated": HyperParams.edit_params(
company_id,
user_id=call.identity.user,
task_id=request.task,
hyperparams=request.hyperparams,
replace_hyperparams=request.replace_hyperparams,
@@ -769,6 +785,7 @@ def delete_hyper_params(call: APICall, company_id, request: DeleteHyperParamsReq
call.result.data = {
"deleted": HyperParams.delete_params(
company_id,
user_id=call.identity.user,
task_id=request.task,
hyperparams=request.hyperparams,
force=request.force,
@@ -813,6 +830,7 @@ def edit_configuration(call: APICall, company_id, request: EditConfigurationRequ
call.result.data = {
"updated": HyperParams.edit_configuration(
company_id,
user_id=call.identity.user,
task_id=request.task,
configuration=request.configuration,
replace_configuration=request.replace_configuration,
@@ -828,6 +846,7 @@ def delete_configuration(
call.result.data = {
"deleted": HyperParams.delete_configuration(
company_id,
user_id=call.identity.user,
task_id=request.task,
configuration=request.configuration,
force=request.force,
@@ -844,6 +863,7 @@ def enqueue(call: APICall, company_id, request: EnqueueRequest):
queued, res = enqueue_task(
task_id=request.task,
company_id=company_id,
user_id=call.identity.user,
queue_id=request.queue,
status_message=request.status_message,
status_reason=request.status_reason,
@@ -868,6 +888,7 @@ def enqueue_many(call: APICall, company_id, request: EnqueueManyRequest):
func=partial(
enqueue_task,
company_id=company_id,
user_id=call.identity.user,
queue_id=request.queue,
status_message=request.status_message,
status_reason=request.status_reason,
@@ -894,32 +915,34 @@ def enqueue_many(call: APICall, company_id, request: EnqueueManyRequest):
@endpoint(
"tasks.dequeue",
request_data_model=UpdateRequest,
response_data_model=DequeueResponse,
"tasks.dequeue", response_data_model=DequeueResponse,
)
def dequeue(call: APICall, company_id, request: UpdateRequest):
def dequeue(call: APICall, company_id, request: DequeueRequest):
dequeued, res = dequeue_task(
task_id=request.task,
company_id=company_id,
user_id=call.identity.user,
status_message=request.status_message,
status_reason=request.status_reason,
remove_from_all_queues=request.remove_from_all_queues,
new_status=request.new_status,
)
call.result.data_model = DequeueResponse(dequeued=dequeued, **res)
@endpoint(
"tasks.dequeue_many",
request_data_model=TaskBatchRequest,
response_data_model=DequeueManyResponse,
"tasks.dequeue_many", response_data_model=DequeueManyResponse,
)
def dequeue_many(call: APICall, company_id, request: TaskBatchRequest):
def dequeue_many(call: APICall, company_id, request: DequeueManyRequest):
results, failures = run_batch_operation(
func=partial(
dequeue_task,
company_id=company_id,
user_id=call.identity.user,
status_message=request.status_message,
status_reason=request.status_reason,
remove_from_all_queues=request.remove_from_all_queues,
new_status=request.new_status,
),
ids=request.ids,
)
@@ -1007,12 +1030,21 @@ def archive(call: APICall, company_id, request: ArchiveRequest):
tasks = TaskBLL.assert_exists(
company_id,
task_ids=request.tasks,
only=("id", "execution", "status", "project", "system_tags", "enqueue_status"),
only=(
"id",
"company",
"execution",
"status",
"project",
"system_tags",
"enqueue_status",
),
)
archived = 0
for task in tasks:
archived += archive_task(
company_id=company_id,
user_id=call.identity.user,
task=task,
status_message=request.status_message,
status_reason=request.status_reason,
@@ -1031,6 +1063,7 @@ def archive_many(call: APICall, company_id, request: TaskBatchRequest):
func=partial(
archive_task,
company_id=company_id,
user_id=call.identity.user,
status_message=request.status_message,
status_reason=request.status_reason,
),
@@ -1052,6 +1085,7 @@ def unarchive_many(call: APICall, company_id, request: TaskBatchRequest):
func=partial(
unarchive_task,
company_id=company_id,
user_id=call.identity.user,
status_message=request.status_message,
status_reason=request.status_reason,
),
@@ -1130,6 +1164,7 @@ def publish(call: APICall, company_id, request: PublishRequest):
updates = publish_task(
task_id=request.task,
company_id=company_id,
user_id=call.identity.user,
force=request.force,
publish_model_func=ModelBLL.publish_model if request.publish_model else None,
status_reason=request.status_reason,
@@ -1148,6 +1183,7 @@ def publish_many(call: APICall, company_id, request: PublishManyRequest):
func=partial(
publish_task,
company_id=company_id,
user_id=call.identity.user,
force=request.force,
publish_model_func=ModelBLL.publish_model
if request.publish_model
@@ -1174,7 +1210,8 @@ def completed(call: APICall, company_id, request: CompletedRequest):
res = CompletedResponse(
**set_task_status_from_call(
request,
company_id,
company_id=company_id,
user_id=call.identity.user,
new_status=TaskStatus.completed,
completed=datetime.utcnow(),
)
@@ -1184,6 +1221,7 @@ def completed(call: APICall, company_id, request: CompletedRequest):
publish_res = publish_task(
task_id=request.task,
company_id=company_id,
user_id=call.identity.user,
force=request.force,
publish_model_func=ModelBLL.publish_model,
status_reason=request.status_reason,
@@ -1198,9 +1236,12 @@ def completed(call: APICall, company_id, request: CompletedRequest):
@endpoint("tasks.ping", request_data_model=PingRequest)
def ping(_, company_id, request: PingRequest):
def ping(call: APICall, company_id, request: PingRequest):
TaskBLL.set_last_update(
task_ids=[request.task], company_id=company_id, last_update=datetime.utcnow()
task_ids=[request.task],
company_id=company_id,
user_id=call.identity.user,
last_update=datetime.utcnow(),
)
@@ -1215,6 +1256,7 @@ def add_or_update_artifacts(
call.result.data = {
"updated": Artifacts.add_or_update_artifacts(
company_id=company_id,
user_id=call.identity.user,
task_id=request.task,
artifacts=request.artifacts,
force=True,
@@ -1231,6 +1273,7 @@ def delete_artifacts(call: APICall, company_id, request: DeleteArtifactsRequest)
call.result.data = {
"deleted": Artifacts.delete_artifacts(
company_id=company_id,
user_id=call.identity.user,
task_id=request.task,
artifact_ids=request.artifacts,
force=True,
@@ -1241,20 +1284,28 @@ def delete_artifacts(call: APICall, company_id, request: DeleteArtifactsRequest)
@endpoint("tasks.make_public", min_version="2.9", request_data_model=MakePublicRequest)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Task.set_public(
company_id, request.ids, invalid_cls=InvalidTaskId, enabled=True
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidTaskId,
enabled=True,
)
@endpoint("tasks.make_private", min_version="2.9", request_data_model=MakePublicRequest)
def make_public(call: APICall, company_id, request: MakePublicRequest):
call.result.data = Task.set_public(
company_id, request.ids, invalid_cls=InvalidTaskId, enabled=False
company_id=company_id,
user_id=call.identity.user,
ids=request.ids,
invalid_cls=InvalidTaskId,
enabled=False,
)
@endpoint("tasks.move", request_data_model=MoveRequest)
def move(call: APICall, company_id: str, request: MoveRequest):
if not (request.project or request.project_name):
if not ("project" in call.data or request.project_name):
raise errors.bad_request.MissingRequiredFields(
"project or project_name is required"
)
@@ -1278,8 +1329,21 @@ def move(call: APICall, company_id: str, request: MoveRequest):
return {"project_id": project_id}
@endpoint("tasks.update_tags")
def update_tags(_, company_id: str, request: UpdateTagsRequest):
return {
"updated": org_bll.edit_entity_tags(
company_id=company_id,
entity_cls=Task,
entity_ids=request.ids,
add_tags=request.add_tags,
remove_tags=request.remove_tags,
)
}
@endpoint("tasks.add_or_update_model", min_version="2.13")
def add_or_update_model(_: APICall, company_id: str, request: AddUpdateModelRequest):
def add_or_update_model(call: APICall, company_id: str, request: AddUpdateModelRequest):
get_task_for_update(company_id=company_id, task_id=request.task, force=True)
models_field = f"models__{request.type}"
@@ -1290,6 +1354,7 @@ def add_or_update_model(_: APICall, company_id: str, request: AddUpdateModelRequ
updated = TaskBLL.update_statistics(
task_id=request.task,
company_id=company_id,
user_id=call.identity.user,
last_iteration_max=request.iteration,
**({f"push__{models_field}": model} if not updated else {}),
)
@@ -1298,7 +1363,7 @@ def add_or_update_model(_: APICall, company_id: str, request: AddUpdateModelRequ
@endpoint("tasks.delete_models", min_version="2.13")
def delete_models(_: APICall, company_id: str, request: DeleteModelsRequest):
def delete_models(call: APICall, company_id: str, request: DeleteModelsRequest):
task = get_task_for_update(company_id=company_id, task_id=request.task, force=True)
delete_names = {
@@ -1311,5 +1376,7 @@ def delete_models(_: APICall, company_id: str, request: DeleteModelsRequest):
if names
}
updated = task.update(last_change=datetime.utcnow(), **commands,)
updated = task.update(
last_change=datetime.utcnow(), last_changed_by=call.identity.user, **commands,
)
return {"updated": updated}

View File

@@ -98,9 +98,7 @@ def get_current_user(call: APICall, company_id, _):
user_id = call.identity.user
projection = (
{"company.name"}
.union(User.get_fields())
.difference(User.get_exclude_fields())
{"company.name"}.union(User.get_fields()).difference(User.get_exclude_fields())
)
res = User.get_many_with_join(
query=Q(id=user_id),
@@ -114,9 +112,13 @@ def get_current_user(call: APICall, company_id, _):
user = res[0]
user["role"] = call.identity.role
resp = {
"user": user,
"getting_started": config.get("apiserver.getting_started_info", None),
resp = dict(
user=user, getting_started=config.get("apiserver.getting_started_info", None)
)
resp["settings"] = {
"max_download_items": int(
config.get("services.organization.download.max_download_items", 1000)
)
}
call.result.data = resp

View File

@@ -101,7 +101,7 @@ def _upgrade_tags(call: APICall, tags: Sequence, system_tags: Sequence):
def validate_tags(tags: Sequence[str], system_tags: Sequence[str]):
for values in filter(None, (tags, system_tags)):
unsupported = [
t for t in values if t.startswith(GetMixin.ListFieldBucketHelper.op_prefix)
t for t in values if t.startswith(GetMixin.NewListFieldBucketHelper.op_prefix)
]
if unsupported:
raise errors.bad_request.FieldsValueError(

View File

@@ -22,6 +22,7 @@ from apiserver.apimodels.workers import (
GetActivityReportRequest,
GetActivityReportResponse,
ActivityReportSeries,
GetCountRequest,
)
from apiserver.bll.workers import WorkerBLL
from apiserver.config_repo import config
@@ -50,12 +51,29 @@ def get_all(call: APICall, company_id: str, request: GetAllRequest):
)
@endpoint(
"workers.get_count", request_data_model=GetCountRequest,
)
def get_all(call: APICall, company_id: str, request: GetCountRequest):
call.result.data = {
"count": worker_bll.get_count(
company_id,
request.last_seen,
tags=request.tags,
system_tags=request.system_tags,
)
}
@endpoint("workers.register", min_version="2.4", request_data_model=RegisterRequest)
def register(call: APICall, company_id, request: RegisterRequest):
worker = request.worker
timeout = request.timeout
queues = request.queues
if not timeout:
timeout = config.get("apiserver.workers.default_timeout", 10 * 60)
if not timeout or timeout <= 0:
raise bad_request.WorkerRegistrationFailed(
"invalid timeout", timeout=timeout, worker=worker

Some files were not shown because too many files have changed in this diff Show More