labtasker-server 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labtasker_server/__init__.py +3 -0
- labtasker_server/__main__.py +3 -0
- labtasker_server/app.py +416 -0
- labtasker_server/cli.py +371 -0
- labtasker_server/config.py +42 -0
- labtasker_server/database.py +176 -0
- labtasker_server/errors.py +27 -0
- labtasker_server/filtering.py +528 -0
- labtasker_server/local.py +453 -0
- labtasker_server/logging.py +47 -0
- labtasker_server/middleware.py +73 -0
- labtasker_server/migrations/__init__.py +1 -0
- labtasker_server/migrations/env.py +20 -0
- labtasker_server/migrations/versions/0001_initial.py +135 -0
- labtasker_server/migrations/versions/__init__.py +1 -0
- labtasker_server/models.py +139 -0
- labtasker_server/pagination.py +126 -0
- labtasker_server/py.typed +1 -0
- labtasker_server/schemas.py +265 -0
- labtasker_server/services/__init__.py +1 -0
- labtasker_server/services/queues.py +66 -0
- labtasker_server/services/tasks.py +859 -0
- labtasker_server/validation.py +150 -0
- labtasker_server-2.0.0.dist-info/METADATA +16 -0
- labtasker_server-2.0.0.dist-info/RECORD +28 -0
- labtasker_server-2.0.0.dist-info/WHEEL +4 -0
- labtasker_server-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_server-2.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from alembic import op
|
|
7
|
+
|
|
8
|
+
revision: str = "0001_initial"
|
|
9
|
+
down_revision: str | None = None
|
|
10
|
+
branch_labels: str | Sequence[str] | None = None
|
|
11
|
+
depends_on: str | Sequence[str] | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def upgrade() -> None:
|
|
15
|
+
op.create_table("queues", sa.Column("name", sa.String(length=128), primary_key=True))
|
|
16
|
+
op.create_table(
|
|
17
|
+
"tasks",
|
|
18
|
+
sa.Column("queue_name", sa.String(length=128), nullable=False),
|
|
19
|
+
sa.Column("task_id", sa.String(length=14), nullable=False),
|
|
20
|
+
sa.Column("status", sa.String(length=16), nullable=False),
|
|
21
|
+
sa.Column("name", sa.String(length=256)),
|
|
22
|
+
sa.Column("args_json", sa.Text(), nullable=False),
|
|
23
|
+
sa.Column("metadata_json", sa.Text(), nullable=False),
|
|
24
|
+
sa.Column("result_json", sa.Text(), nullable=False),
|
|
25
|
+
sa.Column("priority", sa.Integer(), nullable=False),
|
|
26
|
+
sa.Column("attempt", sa.Integer(), nullable=False),
|
|
27
|
+
sa.Column("max_attempts", sa.Integer(), nullable=False),
|
|
28
|
+
sa.Column("created_at_us", sa.Integer(), nullable=False),
|
|
29
|
+
sa.Column("updated_at_us", sa.Integer(), nullable=False),
|
|
30
|
+
sa.Column("last_route", sa.String(length=128)),
|
|
31
|
+
sa.Column("started_at_us", sa.Integer()),
|
|
32
|
+
sa.Column("finished_at_us", sa.Integer()),
|
|
33
|
+
sa.Column("last_error_json", sa.Text()),
|
|
34
|
+
sa.Column("creation_hash", sa.String(length=64), nullable=False),
|
|
35
|
+
sa.Column("active_run_id", sa.String(length=14)),
|
|
36
|
+
sa.Column("lease_expires_at_us", sa.Integer()),
|
|
37
|
+
sa.Column("last_terminal_run_id", sa.String(length=14)),
|
|
38
|
+
sa.Column("last_terminal_action", sa.String(length=32)),
|
|
39
|
+
sa.Column("pending_at_us", sa.Integer()),
|
|
40
|
+
sa.CheckConstraint(
|
|
41
|
+
"status IN ('pending','running','succeeded','failed','cancelled')",
|
|
42
|
+
name="ck_tasks_status",
|
|
43
|
+
),
|
|
44
|
+
sa.CheckConstraint("attempt >= 0", name="ck_tasks_attempt_nonnegative"),
|
|
45
|
+
sa.CheckConstraint("max_attempts > 0", name="ck_tasks_max_attempts_positive"),
|
|
46
|
+
sa.CheckConstraint(
|
|
47
|
+
"last_terminal_action IS NULL OR last_terminal_action IN "
|
|
48
|
+
"('complete','fail','unclaim','heartbeat_expired','cancel')",
|
|
49
|
+
name="ck_tasks_terminal_action",
|
|
50
|
+
),
|
|
51
|
+
sa.CheckConstraint(
|
|
52
|
+
"json_valid(args_json) AND json_type(args_json) = 'object'",
|
|
53
|
+
name="ck_args_json",
|
|
54
|
+
),
|
|
55
|
+
sa.CheckConstraint(
|
|
56
|
+
"json_valid(metadata_json) AND json_type(metadata_json) = 'object'",
|
|
57
|
+
name="ck_metadata_json",
|
|
58
|
+
),
|
|
59
|
+
sa.CheckConstraint(
|
|
60
|
+
"json_valid(result_json) AND json_type(result_json) = 'object'",
|
|
61
|
+
name="ck_result_json",
|
|
62
|
+
),
|
|
63
|
+
sa.CheckConstraint(
|
|
64
|
+
"(status = 'pending' AND pending_at_us IS NOT NULL "
|
|
65
|
+
"AND active_run_id IS NULL AND lease_expires_at_us IS NULL "
|
|
66
|
+
"AND attempt < max_attempts) OR status != 'pending'",
|
|
67
|
+
name="ck_tasks_pending_state",
|
|
68
|
+
),
|
|
69
|
+
sa.CheckConstraint(
|
|
70
|
+
"(status = 'running' AND pending_at_us IS NULL "
|
|
71
|
+
"AND active_run_id IS NOT NULL AND lease_expires_at_us IS NOT NULL) "
|
|
72
|
+
"OR status != 'running'",
|
|
73
|
+
name="ck_tasks_running_state",
|
|
74
|
+
),
|
|
75
|
+
sa.CheckConstraint(
|
|
76
|
+
"(status IN ('succeeded','failed','cancelled') AND pending_at_us IS NULL "
|
|
77
|
+
"AND active_run_id IS NULL AND lease_expires_at_us IS NULL) "
|
|
78
|
+
"OR status NOT IN ('succeeded','failed','cancelled')",
|
|
79
|
+
name="ck_tasks_terminal_state",
|
|
80
|
+
),
|
|
81
|
+
sa.ForeignKeyConstraint(["queue_name"], ["queues.name"], ondelete="CASCADE"),
|
|
82
|
+
sa.PrimaryKeyConstraint("queue_name", "task_id"),
|
|
83
|
+
)
|
|
84
|
+
op.create_index(
|
|
85
|
+
"ix_tasks_claim",
|
|
86
|
+
"tasks",
|
|
87
|
+
["queue_name", "status", sa.text("priority DESC"), "pending_at_us", "task_id"],
|
|
88
|
+
)
|
|
89
|
+
op.create_index("ix_tasks_expiry", "tasks", ["status", "lease_expires_at_us"])
|
|
90
|
+
op.create_index(
|
|
91
|
+
"ix_tasks_default_list",
|
|
92
|
+
"tasks",
|
|
93
|
+
["queue_name", sa.text("created_at_us DESC"), sa.text("task_id DESC")],
|
|
94
|
+
)
|
|
95
|
+
op.create_index(
|
|
96
|
+
"ix_tasks_status_list",
|
|
97
|
+
"tasks",
|
|
98
|
+
["queue_name", "status", sa.text("created_at_us DESC"), sa.text("task_id DESC")],
|
|
99
|
+
)
|
|
100
|
+
op.create_index(
|
|
101
|
+
"uq_tasks_active_run_id",
|
|
102
|
+
"tasks",
|
|
103
|
+
["active_run_id"],
|
|
104
|
+
unique=True,
|
|
105
|
+
sqlite_where=sa.text("active_run_id IS NOT NULL"),
|
|
106
|
+
)
|
|
107
|
+
op.create_index(
|
|
108
|
+
"ix_tasks_terminal_run_id",
|
|
109
|
+
"tasks",
|
|
110
|
+
["last_terminal_run_id"],
|
|
111
|
+
sqlite_where=sa.text("last_terminal_run_id IS NOT NULL"),
|
|
112
|
+
)
|
|
113
|
+
op.create_table(
|
|
114
|
+
"task_routes",
|
|
115
|
+
sa.Column("queue_name", sa.String(length=128), nullable=False),
|
|
116
|
+
sa.Column("task_id", sa.String(length=14), nullable=False),
|
|
117
|
+
sa.Column("route", sa.String(length=128), nullable=False),
|
|
118
|
+
sa.ForeignKeyConstraint(
|
|
119
|
+
["queue_name", "task_id"],
|
|
120
|
+
["tasks.queue_name", "tasks.task_id"],
|
|
121
|
+
ondelete="CASCADE",
|
|
122
|
+
),
|
|
123
|
+
sa.PrimaryKeyConstraint("queue_name", "task_id", "route"),
|
|
124
|
+
)
|
|
125
|
+
op.create_index(
|
|
126
|
+
"ix_task_routes_route",
|
|
127
|
+
"task_routes",
|
|
128
|
+
["queue_name", "route", "task_id"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def downgrade() -> None:
|
|
133
|
+
op.drop_table("task_routes")
|
|
134
|
+
op.drop_table("tasks")
|
|
135
|
+
op.drop_table("queues")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Labtasker v2 schema revisions."""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import (
|
|
4
|
+
CheckConstraint,
|
|
5
|
+
ForeignKeyConstraint,
|
|
6
|
+
Index,
|
|
7
|
+
Integer,
|
|
8
|
+
String,
|
|
9
|
+
Text,
|
|
10
|
+
text,
|
|
11
|
+
)
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Base(DeclarativeBase):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QueueRow(Base):
|
|
20
|
+
__tablename__ = "queues"
|
|
21
|
+
|
|
22
|
+
name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskRow(Base):
|
|
26
|
+
__tablename__ = "tasks"
|
|
27
|
+
__table_args__ = (
|
|
28
|
+
ForeignKeyConstraint(["queue_name"], ["queues.name"], ondelete="CASCADE"),
|
|
29
|
+
CheckConstraint(
|
|
30
|
+
"status IN ('pending','running','succeeded','failed','cancelled')",
|
|
31
|
+
name="ck_tasks_status",
|
|
32
|
+
),
|
|
33
|
+
CheckConstraint("attempt >= 0", name="ck_tasks_attempt_nonnegative"),
|
|
34
|
+
CheckConstraint("max_attempts > 0", name="ck_tasks_max_attempts_positive"),
|
|
35
|
+
CheckConstraint(
|
|
36
|
+
"last_terminal_action IS NULL OR last_terminal_action IN "
|
|
37
|
+
"('complete','fail','unclaim','heartbeat_expired','cancel')",
|
|
38
|
+
name="ck_tasks_terminal_action",
|
|
39
|
+
),
|
|
40
|
+
CheckConstraint(
|
|
41
|
+
"json_valid(args_json) AND json_type(args_json) = 'object'",
|
|
42
|
+
name="ck_args_json",
|
|
43
|
+
),
|
|
44
|
+
CheckConstraint(
|
|
45
|
+
"json_valid(metadata_json) AND json_type(metadata_json) = 'object'",
|
|
46
|
+
name="ck_metadata_json",
|
|
47
|
+
),
|
|
48
|
+
CheckConstraint(
|
|
49
|
+
"json_valid(result_json) AND json_type(result_json) = 'object'",
|
|
50
|
+
name="ck_result_json",
|
|
51
|
+
),
|
|
52
|
+
CheckConstraint(
|
|
53
|
+
"(status = 'pending' AND pending_at_us IS NOT NULL "
|
|
54
|
+
"AND active_run_id IS NULL AND lease_expires_at_us IS NULL "
|
|
55
|
+
"AND attempt < max_attempts) OR status != 'pending'",
|
|
56
|
+
name="ck_tasks_pending_state",
|
|
57
|
+
),
|
|
58
|
+
CheckConstraint(
|
|
59
|
+
"(status = 'running' AND pending_at_us IS NULL "
|
|
60
|
+
"AND active_run_id IS NOT NULL AND lease_expires_at_us IS NOT NULL) "
|
|
61
|
+
"OR status != 'running'",
|
|
62
|
+
name="ck_tasks_running_state",
|
|
63
|
+
),
|
|
64
|
+
CheckConstraint(
|
|
65
|
+
"(status IN ('succeeded','failed','cancelled') AND pending_at_us IS NULL "
|
|
66
|
+
"AND active_run_id IS NULL AND lease_expires_at_us IS NULL) "
|
|
67
|
+
"OR status NOT IN ('succeeded','failed','cancelled')",
|
|
68
|
+
name="ck_tasks_terminal_state",
|
|
69
|
+
),
|
|
70
|
+
Index(
|
|
71
|
+
"ix_tasks_claim",
|
|
72
|
+
"queue_name",
|
|
73
|
+
"status",
|
|
74
|
+
"priority",
|
|
75
|
+
"pending_at_us",
|
|
76
|
+
"task_id",
|
|
77
|
+
),
|
|
78
|
+
Index("ix_tasks_expiry", "status", "lease_expires_at_us"),
|
|
79
|
+
Index("ix_tasks_default_list", "queue_name", "created_at_us", "task_id"),
|
|
80
|
+
Index("ix_tasks_status_list", "queue_name", "status", "created_at_us", "task_id"),
|
|
81
|
+
Index(
|
|
82
|
+
"uq_tasks_active_run_id",
|
|
83
|
+
"active_run_id",
|
|
84
|
+
unique=True,
|
|
85
|
+
sqlite_where=text("active_run_id IS NOT NULL"),
|
|
86
|
+
),
|
|
87
|
+
Index(
|
|
88
|
+
"ix_tasks_terminal_run_id",
|
|
89
|
+
"last_terminal_run_id",
|
|
90
|
+
sqlite_where=text("last_terminal_run_id IS NOT NULL"),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
queue_name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
95
|
+
task_id: Mapped[str] = mapped_column(String(14), primary_key=True)
|
|
96
|
+
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
97
|
+
name: Mapped[str | None] = mapped_column(String(256))
|
|
98
|
+
args_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
99
|
+
metadata_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
100
|
+
result_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
101
|
+
priority: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
102
|
+
attempt: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
103
|
+
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
104
|
+
created_at_us: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
105
|
+
updated_at_us: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
106
|
+
last_route: Mapped[str | None] = mapped_column(String(128))
|
|
107
|
+
started_at_us: Mapped[int | None] = mapped_column(Integer)
|
|
108
|
+
finished_at_us: Mapped[int | None] = mapped_column(Integer)
|
|
109
|
+
last_error_json: Mapped[str | None] = mapped_column(Text)
|
|
110
|
+
creation_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
111
|
+
active_run_id: Mapped[str | None] = mapped_column(String(14))
|
|
112
|
+
lease_expires_at_us: Mapped[int | None] = mapped_column(Integer)
|
|
113
|
+
last_terminal_run_id: Mapped[str | None] = mapped_column(String(14))
|
|
114
|
+
last_terminal_action: Mapped[str | None] = mapped_column(String(32))
|
|
115
|
+
pending_at_us: Mapped[int | None] = mapped_column(Integer)
|
|
116
|
+
|
|
117
|
+
routes: Mapped[list[TaskRouteRow]] = relationship(
|
|
118
|
+
back_populates="task",
|
|
119
|
+
cascade="all, delete-orphan",
|
|
120
|
+
lazy="selectin",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TaskRouteRow(Base):
|
|
125
|
+
__tablename__ = "task_routes"
|
|
126
|
+
__table_args__ = (
|
|
127
|
+
ForeignKeyConstraint(
|
|
128
|
+
["queue_name", "task_id"],
|
|
129
|
+
["tasks.queue_name", "tasks.task_id"],
|
|
130
|
+
ondelete="CASCADE",
|
|
131
|
+
),
|
|
132
|
+
Index("ix_task_routes_route", "queue_name", "route", "task_id"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
queue_name: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
136
|
+
task_id: Mapped[str] = mapped_column(String(14), primary_key=True)
|
|
137
|
+
route: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
138
|
+
|
|
139
|
+
task: Mapped[TaskRow] = relationship(back_populates="routes")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from labtasker_server.errors import DomainError, invalid
|
|
9
|
+
from labtasker_server.validation import (
|
|
10
|
+
INT64_MAX,
|
|
11
|
+
INT64_MIN,
|
|
12
|
+
validate_task_id,
|
|
13
|
+
validate_unicode_scalar,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class TaskSelection:
|
|
19
|
+
queue: str
|
|
20
|
+
status: str | None
|
|
21
|
+
name: str | None
|
|
22
|
+
filter: str | None
|
|
23
|
+
order_by: str
|
|
24
|
+
descending: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class CursorPosition:
|
|
29
|
+
value: str | int | None
|
|
30
|
+
task_id: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_cursor(selection: TaskSelection, position: CursorPosition) -> str:
|
|
34
|
+
payload = {
|
|
35
|
+
"v": 1,
|
|
36
|
+
"selection": {
|
|
37
|
+
"queue": selection.queue,
|
|
38
|
+
"status": selection.status,
|
|
39
|
+
"name": selection.name,
|
|
40
|
+
"filter": selection.filter,
|
|
41
|
+
"order_by": selection.order_by,
|
|
42
|
+
"descending": selection.descending,
|
|
43
|
+
},
|
|
44
|
+
"position": {"value": position.value, "id": position.task_id},
|
|
45
|
+
}
|
|
46
|
+
encoded = json.dumps(
|
|
47
|
+
payload,
|
|
48
|
+
ensure_ascii=False,
|
|
49
|
+
allow_nan=False,
|
|
50
|
+
separators=(",", ":"),
|
|
51
|
+
sort_keys=True,
|
|
52
|
+
).encode("utf-8")
|
|
53
|
+
return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decode_cursor(cursor: str, selection: TaskSelection) -> CursorPosition:
|
|
57
|
+
try:
|
|
58
|
+
if not cursor or len(cursor) > 16_384 or not cursor.isascii():
|
|
59
|
+
raise ValueError
|
|
60
|
+
padding = "=" * (-len(cursor) % 4)
|
|
61
|
+
raw = base64.b64decode(cursor + padding, altchars=b"-_", validate=True)
|
|
62
|
+
payload = json.loads(raw)
|
|
63
|
+
if not isinstance(payload, dict) or set(payload) != {"v", "selection", "position"}:
|
|
64
|
+
raise ValueError
|
|
65
|
+
if payload["v"] != 1 or payload["selection"] != _selection_json(selection):
|
|
66
|
+
raise ValueError
|
|
67
|
+
position = payload["position"]
|
|
68
|
+
if not isinstance(position, dict) or set(position) != {"value", "id"}:
|
|
69
|
+
raise ValueError
|
|
70
|
+
task_id = position["id"]
|
|
71
|
+
value = position["value"]
|
|
72
|
+
if not isinstance(task_id, str) or isinstance(value, (bool, float, list, dict)):
|
|
73
|
+
raise ValueError
|
|
74
|
+
validate_task_id(task_id)
|
|
75
|
+
_validate_cursor_value(selection.order_by, value)
|
|
76
|
+
return CursorPosition(value=value, task_id=task_id)
|
|
77
|
+
except (
|
|
78
|
+
UnicodeError,
|
|
79
|
+
ValueError,
|
|
80
|
+
TypeError,
|
|
81
|
+
json.JSONDecodeError,
|
|
82
|
+
binascii.Error,
|
|
83
|
+
DomainError,
|
|
84
|
+
) as error:
|
|
85
|
+
raise invalid(
|
|
86
|
+
"invalid_cursor", "Cursor is malformed or does not match this request."
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _selection_json(selection: TaskSelection) -> dict[str, object]:
|
|
91
|
+
return {
|
|
92
|
+
"queue": selection.queue,
|
|
93
|
+
"status": selection.status,
|
|
94
|
+
"name": selection.name,
|
|
95
|
+
"filter": selection.filter,
|
|
96
|
+
"order_by": selection.order_by,
|
|
97
|
+
"descending": selection.descending,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_cursor_value(order_by: str, value: object) -> None:
|
|
102
|
+
numeric_fields = {
|
|
103
|
+
"priority",
|
|
104
|
+
"attempt",
|
|
105
|
+
"max_attempts",
|
|
106
|
+
"created_at",
|
|
107
|
+
"updated_at",
|
|
108
|
+
"started_at",
|
|
109
|
+
"finished_at",
|
|
110
|
+
}
|
|
111
|
+
nullable_fields = {"name", "last_route", "started_at", "finished_at"}
|
|
112
|
+
if value is None:
|
|
113
|
+
if order_by not in nullable_fields:
|
|
114
|
+
raise ValueError
|
|
115
|
+
return
|
|
116
|
+
if order_by in numeric_fields:
|
|
117
|
+
if (
|
|
118
|
+
not isinstance(value, int)
|
|
119
|
+
or isinstance(value, bool)
|
|
120
|
+
or not INT64_MIN <= value <= INT64_MAX
|
|
121
|
+
):
|
|
122
|
+
raise ValueError
|
|
123
|
+
return
|
|
124
|
+
if not isinstance(value, str):
|
|
125
|
+
raise ValueError
|
|
126
|
+
validate_unicode_scalar(value, field="cursor.position.value")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Annotated, Any, Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator
|
|
8
|
+
from pydantic_core import PydanticCustomError
|
|
9
|
+
|
|
10
|
+
from labtasker_server.errors import DomainError
|
|
11
|
+
from labtasker_server.validation import (
|
|
12
|
+
INT64_MAX,
|
|
13
|
+
INT64_MIN,
|
|
14
|
+
JSONValue,
|
|
15
|
+
canonical_routes,
|
|
16
|
+
validate_identifier,
|
|
17
|
+
validate_json_object,
|
|
18
|
+
validate_run_id,
|
|
19
|
+
validate_task_name,
|
|
20
|
+
validate_unicode_scalar,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
TaskStatus = Literal["pending", "running", "succeeded", "failed", "cancelled"]
|
|
24
|
+
TaskOrderField = Literal[
|
|
25
|
+
"id",
|
|
26
|
+
"name",
|
|
27
|
+
"status",
|
|
28
|
+
"priority",
|
|
29
|
+
"attempt",
|
|
30
|
+
"max_attempts",
|
|
31
|
+
"last_route",
|
|
32
|
+
"created_at",
|
|
33
|
+
"updated_at",
|
|
34
|
+
"started_at",
|
|
35
|
+
"finished_at",
|
|
36
|
+
]
|
|
37
|
+
Int64 = Annotated[StrictInt, Field(ge=INT64_MIN, le=INT64_MAX)]
|
|
38
|
+
PositiveInt64 = Annotated[StrictInt, Field(ge=1, le=INT64_MAX)]
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
OMITTED: Any = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StrictModel(BaseModel):
|
|
44
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Queue(StrictModel):
|
|
48
|
+
name: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LastError(StrictModel):
|
|
52
|
+
type: str
|
|
53
|
+
message: str
|
|
54
|
+
traceback: str | None
|
|
55
|
+
occurred_at: datetime
|
|
56
|
+
attempt: int
|
|
57
|
+
run_id: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Task(StrictModel):
|
|
61
|
+
id: str
|
|
62
|
+
queue: str
|
|
63
|
+
status: TaskStatus
|
|
64
|
+
name: str | None
|
|
65
|
+
args: dict[str, JSONValue]
|
|
66
|
+
metadata: dict[str, JSONValue]
|
|
67
|
+
priority: int
|
|
68
|
+
attempt: int
|
|
69
|
+
max_attempts: int
|
|
70
|
+
routes: list[str]
|
|
71
|
+
result: dict[str, JSONValue]
|
|
72
|
+
last_error: LastError | None
|
|
73
|
+
last_route: str | None
|
|
74
|
+
created_at: datetime
|
|
75
|
+
updated_at: datetime
|
|
76
|
+
started_at: datetime | None
|
|
77
|
+
finished_at: datetime | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TaskCreate(StrictModel):
|
|
81
|
+
name: str | None = None
|
|
82
|
+
args: dict[str, JSONValue] = Field(default_factory=dict)
|
|
83
|
+
metadata: dict[str, JSONValue] = Field(default_factory=dict)
|
|
84
|
+
priority: Int64 = 0
|
|
85
|
+
max_attempts: PositiveInt64 = 3
|
|
86
|
+
routes: list[str] = Field(default_factory=lambda: ["default"])
|
|
87
|
+
|
|
88
|
+
@field_validator("name")
|
|
89
|
+
@classmethod
|
|
90
|
+
def validate_name(cls, value: str | None) -> str | None:
|
|
91
|
+
try:
|
|
92
|
+
return validate_task_name(value)
|
|
93
|
+
except DomainError as error:
|
|
94
|
+
raise _pydantic_error(error) from error
|
|
95
|
+
|
|
96
|
+
@field_validator("args", "metadata")
|
|
97
|
+
@classmethod
|
|
98
|
+
def validate_objects(cls, value: dict[str, JSONValue], info: object) -> dict[str, JSONValue]:
|
|
99
|
+
field_name = getattr(info, "field_name", "value")
|
|
100
|
+
try:
|
|
101
|
+
return validate_json_object(value, field=field_name)
|
|
102
|
+
except DomainError as error:
|
|
103
|
+
raise _pydantic_error(error) from error
|
|
104
|
+
|
|
105
|
+
@field_validator("routes")
|
|
106
|
+
@classmethod
|
|
107
|
+
def validate_routes(cls, value: list[str]) -> list[str]:
|
|
108
|
+
try:
|
|
109
|
+
return canonical_routes(value)
|
|
110
|
+
except DomainError as error:
|
|
111
|
+
raise _pydantic_error(error) from error
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TaskUpdate(StrictModel):
|
|
115
|
+
name: str | None = None
|
|
116
|
+
args: dict[str, JSONValue] = OMITTED
|
|
117
|
+
metadata: dict[str, JSONValue] = OMITTED
|
|
118
|
+
priority: Int64 = OMITTED
|
|
119
|
+
max_attempts: PositiveInt64 = OMITTED
|
|
120
|
+
routes: list[str] = OMITTED
|
|
121
|
+
result: dict[str, JSONValue] = OMITTED
|
|
122
|
+
|
|
123
|
+
@model_validator(mode="after")
|
|
124
|
+
def validate_nonempty(self) -> TaskUpdate:
|
|
125
|
+
if not self.model_fields_set:
|
|
126
|
+
raise PydanticCustomError("invalid_update", "Update must contain at least one field.")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
@field_validator("name")
|
|
130
|
+
@classmethod
|
|
131
|
+
def validate_name(cls, value: str | None) -> str | None:
|
|
132
|
+
return _validated(lambda: validate_task_name(value))
|
|
133
|
+
|
|
134
|
+
@field_validator("args", "metadata", "result")
|
|
135
|
+
@classmethod
|
|
136
|
+
def validate_objects(
|
|
137
|
+
cls,
|
|
138
|
+
value: dict[str, JSONValue],
|
|
139
|
+
info: object,
|
|
140
|
+
) -> dict[str, JSONValue]:
|
|
141
|
+
field_name = getattr(info, "field_name", "value")
|
|
142
|
+
return _validated(lambda: validate_json_object(value, field=field_name))
|
|
143
|
+
|
|
144
|
+
@field_validator("routes")
|
|
145
|
+
@classmethod
|
|
146
|
+
def validate_routes(cls, value: list[str]) -> list[str]:
|
|
147
|
+
return _validated(lambda: canonical_routes(value))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class BulkUpdateRequest(StrictModel):
|
|
151
|
+
filter: str
|
|
152
|
+
changes: TaskUpdate
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class BulkUpdateResult(StrictModel):
|
|
156
|
+
matched: int
|
|
157
|
+
updated: int
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TaskPage(StrictModel):
|
|
161
|
+
items: list[Task]
|
|
162
|
+
next_cursor: str | None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CountResponse(StrictModel):
|
|
166
|
+
count: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ClaimRequest(StrictModel):
|
|
170
|
+
route: str
|
|
171
|
+
run_id: str
|
|
172
|
+
|
|
173
|
+
@field_validator("route")
|
|
174
|
+
@classmethod
|
|
175
|
+
def validate_route(cls, value: str) -> str:
|
|
176
|
+
return _validated(lambda: validate_identifier(value, kind="Route"))
|
|
177
|
+
|
|
178
|
+
@field_validator("run_id")
|
|
179
|
+
@classmethod
|
|
180
|
+
def validate_run(cls, value: str) -> str:
|
|
181
|
+
return _validated(lambda: validate_run_id(value))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ClaimResponse(StrictModel):
|
|
185
|
+
task: Task
|
|
186
|
+
run_id: str
|
|
187
|
+
lease_expires_at: datetime
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class RunRequest(StrictModel):
|
|
191
|
+
run_id: str
|
|
192
|
+
|
|
193
|
+
@field_validator("run_id")
|
|
194
|
+
@classmethod
|
|
195
|
+
def validate_run(cls, value: str) -> str:
|
|
196
|
+
return _validated(lambda: validate_run_id(value))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class HeartbeatResponse(StrictModel):
|
|
200
|
+
lease_expires_at: datetime
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class CompleteRequest(RunRequest):
|
|
204
|
+
result: dict[str, JSONValue]
|
|
205
|
+
|
|
206
|
+
@field_validator("result")
|
|
207
|
+
@classmethod
|
|
208
|
+
def validate_result(cls, value: dict[str, JSONValue]) -> dict[str, JSONValue]:
|
|
209
|
+
return _validated(lambda: validate_json_object(value, field="result"))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class FailureReport(StrictModel):
|
|
213
|
+
type: str
|
|
214
|
+
message: str
|
|
215
|
+
traceback: str | None
|
|
216
|
+
|
|
217
|
+
@field_validator("type", "message", "traceback")
|
|
218
|
+
@classmethod
|
|
219
|
+
def validate_strings(cls, value: str | None, info: object) -> str | None:
|
|
220
|
+
if value is None:
|
|
221
|
+
return None
|
|
222
|
+
field_name = getattr(info, "field_name", "error")
|
|
223
|
+
return _validated(lambda: validate_unicode_scalar(value, field=field_name))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class FailRequest(RunRequest):
|
|
227
|
+
error: FailureReport
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ErrorItem(StrictModel):
|
|
231
|
+
location: list[str | int]
|
|
232
|
+
message: str
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ErrorBody(StrictModel):
|
|
236
|
+
code: str
|
|
237
|
+
message: str
|
|
238
|
+
details: dict[str, JSONValue]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ErrorEnvelope(StrictModel):
|
|
242
|
+
error: ErrorBody
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class HealthyResponse(StrictModel):
|
|
246
|
+
status: Literal["ok"]
|
|
247
|
+
api_version: Literal["2"]
|
|
248
|
+
database: Literal["ok"]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class UnhealthyResponse(StrictModel):
|
|
252
|
+
status: Literal["error"]
|
|
253
|
+
api_version: Literal["2"]
|
|
254
|
+
database: Literal["error"]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _pydantic_error(error: DomainError) -> PydanticCustomError:
|
|
258
|
+
return PydanticCustomError(error.code, error.message, error.details)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validated(operation: Callable[[], T]) -> T:
|
|
262
|
+
try:
|
|
263
|
+
return operation()
|
|
264
|
+
except DomainError as error:
|
|
265
|
+
raise _pydantic_error(error) from error
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application services owning Labtasker transactions and domain behavior."""
|