hypershell 2.6.4__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.
- hypershell/__init__.py +115 -0
- hypershell/client.py +1255 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +210 -0
- hypershell/cluster/remote.py +721 -0
- hypershell/cluster/ssh.py +352 -0
- hypershell/config.py +440 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +359 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +82 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +155 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +79 -0
- hypershell/core/sys.py +39 -0
- hypershell/core/tag.py +47 -0
- hypershell/core/template.py +201 -0
- hypershell/core/thread.py +56 -0
- hypershell/core/types.py +32 -0
- hypershell/data/__init__.py +137 -0
- hypershell/data/core.py +232 -0
- hypershell/data/model.py +674 -0
- hypershell/server.py +1207 -0
- hypershell/submit.py +815 -0
- hypershell/task.py +1141 -0
- hypershell-2.6.4.dist-info/LICENSE +201 -0
- hypershell-2.6.4.dist-info/METADATA +117 -0
- hypershell-2.6.4.dist-info/RECORD +33 -0
- hypershell-2.6.4.dist-info/WHEEL +4 -0
- hypershell-2.6.4.dist-info/entry_points.txt +4 -0
hypershell/data/model.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Database models."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import List, Dict, Tuple, Any, Type, TypeVar, Union, Optional
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import re
|
|
13
|
+
import json
|
|
14
|
+
from uuid import uuid4 as gen_uuid
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
# external libs
|
|
18
|
+
from sqlalchemy import Column, Index, func
|
|
19
|
+
from sqlalchemy.orm import Query, DeclarativeBase, Mapped, mapped_column
|
|
20
|
+
from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
|
|
21
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
22
|
+
from sqlalchemy.types import Integer, DateTime, Text, Boolean, JSON as _JSON
|
|
23
|
+
from sqlalchemy.dialects.postgresql import SMALLINT, UUID as POSTGRES_UUID, JSONB as POSTGRES_JSON
|
|
24
|
+
|
|
25
|
+
# internal libs
|
|
26
|
+
from hypershell.core.logging import Logger, HOSTNAME, INSTANCE
|
|
27
|
+
from hypershell.core.heartbeat import Heartbeat
|
|
28
|
+
from hypershell.core.types import JSONValue
|
|
29
|
+
from hypershell.core.tag import Tag
|
|
30
|
+
from hypershell.data.core import schema, Session
|
|
31
|
+
|
|
32
|
+
# public interface
|
|
33
|
+
__all__ = ['Task', 'Client', 'Entity', 'to_json_type', 'from_json_type', ]
|
|
34
|
+
|
|
35
|
+
# initialize logger
|
|
36
|
+
log = Logger.with_name(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DatabaseError(Exception):
|
|
40
|
+
"""Generic database related exception."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFound(DatabaseError):
|
|
44
|
+
"""Exception specific to no record found on lookup by unique field (e.g., `id`)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotDistinct(DatabaseError):
|
|
48
|
+
"""Exception specific to multiple records found when only one should have been."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AlreadyExists(DatabaseError):
|
|
52
|
+
"""Exception specific to a record with unique properties already existing."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Extended value type contains datetime types
|
|
56
|
+
# These are not valid JSON and must be converted
|
|
57
|
+
VT = TypeVar('VT', bool, int, float, str, type(None), datetime)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_json_type(value: VT) -> Union[VT, JSONValue]:
|
|
61
|
+
"""Convert `value` to alternate representation for JSON."""
|
|
62
|
+
return value if not isinstance(value, datetime) else value.isoformat(sep=' ')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def from_json_type(value: JSONValue) -> Union[JSONValue, VT]:
|
|
66
|
+
"""Convert `value` to richer type if possible."""
|
|
67
|
+
try:
|
|
68
|
+
# NOTE: minor detail in PyPy datetime implementation
|
|
69
|
+
if isinstance(value, str) and len(value) > 5:
|
|
70
|
+
return datetime.fromisoformat(value)
|
|
71
|
+
else:
|
|
72
|
+
return value
|
|
73
|
+
except ValueError:
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Pre-defining types shortens declarations and makes changes easier
|
|
78
|
+
UUID = Text().with_variant(POSTGRES_UUID(as_uuid=False), 'postgresql')
|
|
79
|
+
TEXT = Text()
|
|
80
|
+
INTEGER = Integer()
|
|
81
|
+
SMALL_INTEGER = Integer().with_variant(SMALLINT, 'postgresql')
|
|
82
|
+
DATETIME = DateTime(timezone=True)
|
|
83
|
+
BOOLEAN = Boolean()
|
|
84
|
+
JSON = _JSON().with_variant(POSTGRES_JSON(), 'postgresql')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Entity(DeclarativeBase):
|
|
88
|
+
"""Core mixin class for all entities."""
|
|
89
|
+
|
|
90
|
+
columns: Dict[str, type] = {}
|
|
91
|
+
|
|
92
|
+
@declared_attr
|
|
93
|
+
def __tablename__(cls: Type[Entity]) -> str: # noqa: cls
|
|
94
|
+
"""The table name should be lower-case."""
|
|
95
|
+
return cls.__name__.lower()
|
|
96
|
+
|
|
97
|
+
@declared_attr
|
|
98
|
+
def __table_args__(cls) -> Dict[str, Any]: # noqa: cls
|
|
99
|
+
"""Common table attributes."""
|
|
100
|
+
return {'schema': schema, }
|
|
101
|
+
|
|
102
|
+
def __repr__(self: Entity) -> str:
|
|
103
|
+
"""String representation."""
|
|
104
|
+
attrs = ', '.join([f'{name}={repr(getattr(self, name))}' for name in self.columns])
|
|
105
|
+
return f'{self.__class__.__name__}({attrs})'
|
|
106
|
+
|
|
107
|
+
def to_tuple(self: Entity) -> tuple:
|
|
108
|
+
"""Convert fields into standard tuple."""
|
|
109
|
+
return tuple([getattr(self, name) for name in self.columns])
|
|
110
|
+
|
|
111
|
+
def to_dict(self: Entity) -> Dict[str, Any]:
|
|
112
|
+
"""Convert record to dictionary."""
|
|
113
|
+
return dict(zip(self.columns, self.to_tuple()))
|
|
114
|
+
|
|
115
|
+
def to_json(self: Entity) -> Dict[str, JSONValue]:
|
|
116
|
+
"""Convert record to JSON-serializable dictionary."""
|
|
117
|
+
return {key: to_json_type(value) for key, value in self.to_dict().items()}
|
|
118
|
+
|
|
119
|
+
def pack(self: Entity) -> bytes:
|
|
120
|
+
"""Encode as raw JSON bytes."""
|
|
121
|
+
return json.dumps(self.to_json()).encode()
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls: Type[Entity], data: Dict[str, VT]) -> Entity:
|
|
125
|
+
"""Build from existing dictionary."""
|
|
126
|
+
return cls(**data) # noqa: __init__ instrumented by declarative_base
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_json(cls: Type[Entity], data: Dict[str, JSONValue]) -> Entity:
|
|
130
|
+
"""Build from JSON `text` string."""
|
|
131
|
+
return cls.from_dict({key: from_json_type(value) for key, value in data.items()})
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def unpack(cls: Type[Entity], data: bytes) -> Entity:
|
|
135
|
+
"""Unpack raw JSON byte string."""
|
|
136
|
+
return cls.from_json(json.loads(data.decode()))
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def query(cls: Type[Entity], *fields: Column, caching: bool = True) -> Query:
|
|
140
|
+
"""Get query interface for entity with scoped session."""
|
|
141
|
+
target = fields or [cls, ]
|
|
142
|
+
if not caching:
|
|
143
|
+
Session.expire_all()
|
|
144
|
+
return Session.query(*target)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def count(cls: Type[Entity]) -> int:
|
|
148
|
+
"""Count of total existing records in database."""
|
|
149
|
+
return cls.query().count()
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def add_all(cls: Type[Entity], items: List[Entity]) -> List[Entity]:
|
|
153
|
+
"""Add many items to the database at once."""
|
|
154
|
+
# NOTE: pull id first because access after commit could trigger query
|
|
155
|
+
item_ids = [item.id for item in items] # noqa: id not defined on base
|
|
156
|
+
try:
|
|
157
|
+
Session.add_all(items)
|
|
158
|
+
Session.commit()
|
|
159
|
+
except Exception:
|
|
160
|
+
Session.rollback()
|
|
161
|
+
raise
|
|
162
|
+
else:
|
|
163
|
+
for item_id in item_ids:
|
|
164
|
+
log.trace(f'Added {cls.__tablename__} ({item_id})')
|
|
165
|
+
return items
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def add(cls: Type[Entity], item: Entity) -> None:
|
|
169
|
+
"""Add single item to database."""
|
|
170
|
+
cls.add_all([item, ])
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def update_all(cls: Type[Entity], changes: List[Dict[str, Any]]) -> None:
|
|
174
|
+
"""Bulk update."""
|
|
175
|
+
if changes:
|
|
176
|
+
Session.bulk_update_mappings(cls, changes)
|
|
177
|
+
Session.commit() # NOTE: why is this necessary?
|
|
178
|
+
log.trace(f'Updated {len(changes)} {cls.__tablename__}s')
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def update(cls: Type[Entity], id: str, **changes) -> None:
|
|
182
|
+
"""Update by `id` with `changes`."""
|
|
183
|
+
cls.update_all([{'id': id, **changes}, ])
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def delete_all(cls: Type[Entity], items: List[Entity]) -> List[Entity]:
|
|
187
|
+
"""Delete records from database."""
|
|
188
|
+
try:
|
|
189
|
+
for item in items:
|
|
190
|
+
Session.delete(item)
|
|
191
|
+
Session.commit()
|
|
192
|
+
except Exception:
|
|
193
|
+
Session.rollback()
|
|
194
|
+
raise
|
|
195
|
+
else:
|
|
196
|
+
for item in items:
|
|
197
|
+
log.trace(f'Deleted {cls.__tablename__} ({item.id})') # noqa: id not defined on base
|
|
198
|
+
return items
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def delete(cls: Type[Entity], item: Entity) -> None:
|
|
202
|
+
"""Delete single item from database."""
|
|
203
|
+
cls.delete_all([item, ])
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_id(cls: Type[Entity], id: str) -> Entity:
|
|
207
|
+
"""Load by unique `id`."""
|
|
208
|
+
raise NotImplementedError() # NOTE: non-strict requirement of base
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def new(cls: Type[Entity], **attrs: Any) -> Entity:
|
|
212
|
+
"""Create new instance with default values."""
|
|
213
|
+
raise NotImplementedError() # NOTE: non-strict requirement of base
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Task(Entity):
|
|
217
|
+
"""Task entity within database implements task methods."""
|
|
218
|
+
|
|
219
|
+
id: Mapped[str] = mapped_column(UUID, primary_key=True, nullable=False)
|
|
220
|
+
args: Mapped[str] = mapped_column(TEXT, nullable=False)
|
|
221
|
+
|
|
222
|
+
submit_id: Mapped[str] = mapped_column(UUID, nullable=False)
|
|
223
|
+
submit_time: Mapped[datetime] = mapped_column(DATETIME, nullable=False)
|
|
224
|
+
submit_host: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
225
|
+
|
|
226
|
+
server_id: Mapped[Optional[str]] = mapped_column(UUID, nullable=True)
|
|
227
|
+
server_host: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
228
|
+
schedule_time: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
229
|
+
|
|
230
|
+
client_id: Mapped[Optional[str]] = mapped_column(UUID, nullable=True)
|
|
231
|
+
client_host: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
232
|
+
|
|
233
|
+
command: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
234
|
+
start_time: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
235
|
+
completion_time: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
236
|
+
exit_status: Mapped[Optional[int]] = mapped_column(SMALL_INTEGER, nullable=True)
|
|
237
|
+
|
|
238
|
+
outpath: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
239
|
+
errpath: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
240
|
+
|
|
241
|
+
attempt: Mapped[int] = mapped_column(SMALL_INTEGER, nullable=False)
|
|
242
|
+
retried: Mapped[bool] = mapped_column(BOOLEAN, nullable=False)
|
|
243
|
+
|
|
244
|
+
waited: Mapped[Optional[int]] = mapped_column(INTEGER, nullable=True)
|
|
245
|
+
duration: Mapped[Optional[int]] = mapped_column(INTEGER, nullable=True)
|
|
246
|
+
|
|
247
|
+
previous_id: Mapped[Optional[str]] = mapped_column(UUID, unique=True, nullable=True)
|
|
248
|
+
next_id: Mapped[Optional[str]] = mapped_column(UUID, unique=True, nullable=True)
|
|
249
|
+
|
|
250
|
+
tag: Mapped[dict] = mapped_column(JSON, nullable=False, default={})
|
|
251
|
+
|
|
252
|
+
columns = {
|
|
253
|
+
'id': str,
|
|
254
|
+
'args': str,
|
|
255
|
+
'submit_id': str,
|
|
256
|
+
'submit_time': datetime,
|
|
257
|
+
'submit_host': str,
|
|
258
|
+
'server_id': str,
|
|
259
|
+
'server_host': str,
|
|
260
|
+
'schedule_time': datetime,
|
|
261
|
+
'client_id': str,
|
|
262
|
+
'client_host': str,
|
|
263
|
+
'command': str,
|
|
264
|
+
'start_time': datetime,
|
|
265
|
+
'completion_time': datetime,
|
|
266
|
+
'exit_status': int,
|
|
267
|
+
'outpath': str,
|
|
268
|
+
'errpath': str,
|
|
269
|
+
'attempt': int,
|
|
270
|
+
'retried': bool,
|
|
271
|
+
'waited': int,
|
|
272
|
+
'duration': int,
|
|
273
|
+
'previous_id': str,
|
|
274
|
+
'next_id': str,
|
|
275
|
+
'tag': dict,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
class NotFound(NotFound):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
class NotDistinct(NotDistinct):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
class AlreadyExists(AlreadyExists):
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_id(cls: Type[Task], id: str, caching: bool = True) -> Task:
|
|
289
|
+
"""Look up task by unique `id`."""
|
|
290
|
+
try:
|
|
291
|
+
return cls.query(caching=caching).filter_by(id=id).one()
|
|
292
|
+
except NoResultFound as error:
|
|
293
|
+
raise cls.NotFound(f'No task with id={id}') from error
|
|
294
|
+
except MultipleResultsFound as error:
|
|
295
|
+
raise cls.NotDistinct(f'Multiple tasks with id={id}') from error
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def new(cls: Type[Task], args: str, attempt: int = 1, retried: bool = False,
|
|
299
|
+
tag: Dict[str, JSONValue] = None, **other) -> Task:
|
|
300
|
+
"""Create a new Task."""
|
|
301
|
+
cls.ensure_valid_tag(tag)
|
|
302
|
+
args, inline_tags = cls.split_argline(args)
|
|
303
|
+
tag = {**(tag or {}), **inline_tags}
|
|
304
|
+
return Task(id=str(gen_uuid()), args=str(args).strip(),
|
|
305
|
+
submit_id=INSTANCE, submit_host=HOSTNAME, submit_time=datetime.now().astimezone(),
|
|
306
|
+
attempt=attempt, retried=retried, tag=tag, **other)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def split_argline(cls: Type[Task], args: str) -> Tuple[str, Dict[str, JSONValue]]:
|
|
310
|
+
"""Separate input args from possible inline tag comment."""
|
|
311
|
+
if match := re.search(r'#\s*HYPERSHELL:?', args):
|
|
312
|
+
try:
|
|
313
|
+
tags = Tag.parse_cmdline_list(args[match.end():].strip().split())
|
|
314
|
+
cls.ensure_valid_tag(tags)
|
|
315
|
+
except (ValueError, TypeError) as error:
|
|
316
|
+
raise RuntimeError(f'Failed to parse inline tags ({error}, from: "{args}")') from error
|
|
317
|
+
args = args[:match.start()]
|
|
318
|
+
return args, tags
|
|
319
|
+
else:
|
|
320
|
+
return args, {}
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def ensure_valid_tag(tag: Optional[Dict[str, JSONValue]]) -> None:
|
|
324
|
+
"""Check tag dictionary and raise if invalid."""
|
|
325
|
+
if tag is None:
|
|
326
|
+
return
|
|
327
|
+
if not isinstance(tag, dict):
|
|
328
|
+
raise TypeError('Expected dict for tag data')
|
|
329
|
+
for key, value in tag.items():
|
|
330
|
+
if not isinstance(key, str):
|
|
331
|
+
raise TypeError(f'Tag key, {key} ({type(key)}) is not string')
|
|
332
|
+
if len(key.strip()) == 0:
|
|
333
|
+
raise ValueError(f'Tag key was empty, "{key}:{value}"')
|
|
334
|
+
if len(key.strip()) > 120:
|
|
335
|
+
raise ValueError(f'Tag key size ({len(value)}) exceeds 120 characters ({key}:{value})')
|
|
336
|
+
if not re.match(r'^[A-Za-z0-9_.+-]+$', key):
|
|
337
|
+
raise ValueError(f'Tag key must only contain alphanumerics and basic symbols [+._-]: '
|
|
338
|
+
f'"{key}:{value}"')
|
|
339
|
+
if not isinstance(value, (str, int, float, bool, type(None))):
|
|
340
|
+
raise TypeError(f'Invalid type for tag value, {type(value)})')
|
|
341
|
+
if isinstance(value, str):
|
|
342
|
+
if not value.strip():
|
|
343
|
+
return # Empty value is a naked tag (no value).
|
|
344
|
+
if len(value) > 120:
|
|
345
|
+
raise ValueError(f'Tag value size ({len(value)}) exceeds 120 characters ({key}:{value})')
|
|
346
|
+
if not re.match(r'^[A-Za-z0-9_.+-]+$', value):
|
|
347
|
+
raise ValueError(f'Tag value must only contain alphanumerics and basic symbols [+._-]: '
|
|
348
|
+
f'"{key}:{value}"')
|
|
349
|
+
|
|
350
|
+
@classmethod
|
|
351
|
+
def select_new(cls: Type[Task], limit: int) -> List[Task]:
|
|
352
|
+
"""Select unscheduled tasks up to some `limit` in order of submit_time."""
|
|
353
|
+
return (cls.query()
|
|
354
|
+
.order_by(cls.submit_time)
|
|
355
|
+
.filter(cls.schedule_time.is_(None))
|
|
356
|
+
.limit(limit).all())
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def select_failed(cls: Type[Task], attempts: int, limit: int) -> List[Task]:
|
|
360
|
+
"""Select failed tasks for retry up to some `limit` under given number of `attempts`."""
|
|
361
|
+
return (cls.query()
|
|
362
|
+
.order_by(cls.completion_time)
|
|
363
|
+
.filter(cls.exit_status.isnot(None))
|
|
364
|
+
.filter(cls.exit_status != 0)
|
|
365
|
+
.filter(cls.attempt < attempts)
|
|
366
|
+
.filter(cls.retried.is_(False))
|
|
367
|
+
.limit(limit).all())
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def next(cls: Type[Task], limit: int, attempts: int = 1, eager: bool = False) -> List[Task]:
|
|
371
|
+
"""Select tasks for scheduling including failed tasks for re-scheduling."""
|
|
372
|
+
if eager:
|
|
373
|
+
tasks = cls.__next_eager(attempts=attempts, limit=limit)
|
|
374
|
+
else:
|
|
375
|
+
tasks = cls.__next_not_eager(attempts, limit)
|
|
376
|
+
for task in tasks:
|
|
377
|
+
task.server_id = INSTANCE
|
|
378
|
+
task.server_host = HOSTNAME
|
|
379
|
+
task.schedule_time = datetime.now().astimezone()
|
|
380
|
+
Session.commit()
|
|
381
|
+
return tasks
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def __next_eager(cls: Type[Task], attempts: int, limit: int) -> List[Task]:
|
|
385
|
+
"""Select next batch of tasks from database preferring previously failed tasks."""
|
|
386
|
+
tasks = cls.__schedule_next_failed_tasks(attempts, limit)
|
|
387
|
+
if len(tasks) < limit:
|
|
388
|
+
new_tasks = cls.select_new(limit=limit - len(tasks))
|
|
389
|
+
tasks.extend(new_tasks)
|
|
390
|
+
log.trace(f'Selected {len(new_tasks)} new tasks')
|
|
391
|
+
return tasks
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def __next_not_eager(cls: Type[Task], attempts: int, limit: int) -> List[Task]:
|
|
395
|
+
"""Select next batch of tasks for database preferring novel tasks to old failed ones."""
|
|
396
|
+
tasks = cls.select_new(limit=limit)
|
|
397
|
+
log.trace(f'Selected {len(tasks)} new tasks')
|
|
398
|
+
if len(tasks) < limit and attempts > 1:
|
|
399
|
+
failed_tasks = cls.__schedule_next_failed_tasks(attempts=attempts, limit=limit - len(tasks))
|
|
400
|
+
tasks.extend(failed_tasks)
|
|
401
|
+
return tasks
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def __schedule_next_failed_tasks(cls: Type[Task], attempts: int, limit: int) -> List[Task]:
|
|
405
|
+
"""Select previously failed tasks for scheduling."""
|
|
406
|
+
tasks = []
|
|
407
|
+
failed_tasks = cls.select_failed(attempts=attempts, limit=limit)
|
|
408
|
+
if failed_tasks:
|
|
409
|
+
log.trace(f'Selected {len(failed_tasks)} previously failed tasks')
|
|
410
|
+
new_tasks = [cls.new(args=task.args,
|
|
411
|
+
attempt=task.attempt + 1,
|
|
412
|
+
previous_id=task.id,
|
|
413
|
+
tag=task.tag)
|
|
414
|
+
for task in failed_tasks]
|
|
415
|
+
tasks.extend(new_tasks)
|
|
416
|
+
cls.add_all(tasks)
|
|
417
|
+
cls.update_all([{'id': old_task.id, 'retried': True, 'next_id': new_task.id}
|
|
418
|
+
for old_task, new_task in zip(failed_tasks, new_tasks)])
|
|
419
|
+
return tasks
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def count_remaining(cls: Type[Task]) -> int:
|
|
423
|
+
"""Count of remaining unfinished tasks."""
|
|
424
|
+
return cls.query().filter(cls.completion_time.is_(None)).count()
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def count_interrupted(cls: Type[Task]) -> int:
|
|
428
|
+
"""Count tasks that were scheduled but not completed."""
|
|
429
|
+
return (
|
|
430
|
+
cls.query()
|
|
431
|
+
.filter(cls.schedule_time.isnot(None))
|
|
432
|
+
.filter(cls.completion_time.is_(None))
|
|
433
|
+
.count()
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def select_interrupted(cls: Type[Task], limit: int) -> List[Task]:
|
|
438
|
+
"""Select tasks that were scheduled but not completed."""
|
|
439
|
+
return (
|
|
440
|
+
cls.query()
|
|
441
|
+
.order_by(cls.schedule_time)
|
|
442
|
+
.filter(cls.schedule_time.isnot(None))
|
|
443
|
+
.filter(cls.completion_time.is_(None))
|
|
444
|
+
.limit(limit)
|
|
445
|
+
.all()
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def revert_all(cls: Type[Task], ids: List[str]) -> None:
|
|
450
|
+
"""Revert all tasks identified by `ids`."""
|
|
451
|
+
cls.update_all([
|
|
452
|
+
{
|
|
453
|
+
'id': id,
|
|
454
|
+
'schedule_time': None,
|
|
455
|
+
'server_host': None,
|
|
456
|
+
'server_id': None,
|
|
457
|
+
'client_host': None,
|
|
458
|
+
'client_id': None,
|
|
459
|
+
'command': None,
|
|
460
|
+
'start_time': None,
|
|
461
|
+
'completion_time': None,
|
|
462
|
+
'exit_status': None,
|
|
463
|
+
'outpath': None,
|
|
464
|
+
'errpath': None,
|
|
465
|
+
'waited': None,
|
|
466
|
+
'duration': None,
|
|
467
|
+
}
|
|
468
|
+
for id in ids
|
|
469
|
+
])
|
|
470
|
+
for id in ids:
|
|
471
|
+
log.trace(f'Reverted previous task ({id})')
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def revert(cls: Type[Task], id: str) -> None:
|
|
475
|
+
"""Revert single task by `id`."""
|
|
476
|
+
cls.revert_all([id, ])
|
|
477
|
+
|
|
478
|
+
@classmethod
|
|
479
|
+
def revert_interrupted(cls: Type[Task]) -> None:
|
|
480
|
+
"""Revert scheduled but incomplete tasks to un-scheduled state."""
|
|
481
|
+
while tasks := cls.select_interrupted(100):
|
|
482
|
+
cls.revert_all([task.id for task in tasks])
|
|
483
|
+
|
|
484
|
+
@classmethod
|
|
485
|
+
def cancel_all(cls: Type[Task], ids: List[str]) -> None:
|
|
486
|
+
"""Cancel all tasks identified by `ids`."""
|
|
487
|
+
cls.update_all([
|
|
488
|
+
{
|
|
489
|
+
'id': id,
|
|
490
|
+
'schedule_time': datetime.now().astimezone(),
|
|
491
|
+
'exit_status': -1,
|
|
492
|
+
}
|
|
493
|
+
for id in ids
|
|
494
|
+
])
|
|
495
|
+
for id in ids:
|
|
496
|
+
log.trace(f'Cancelled task ({id})')
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def cancel(cls: Type[Task], id: str) -> None:
|
|
500
|
+
"""Cancel single task by `id`."""
|
|
501
|
+
cls.cancel_all([id, ])
|
|
502
|
+
|
|
503
|
+
@classmethod
|
|
504
|
+
def select_orphaned(cls: Type[Task], client_id: str, limit: int) -> List[Task]:
|
|
505
|
+
"""Select tasks that were orphaned from an evicted client."""
|
|
506
|
+
return (
|
|
507
|
+
cls.query()
|
|
508
|
+
.order_by(cls.schedule_time)
|
|
509
|
+
.filter(cls.schedule_time.isnot(None))
|
|
510
|
+
.filter(cls.completion_time.is_(None))
|
|
511
|
+
.filter(cls.client_id == client_id)
|
|
512
|
+
.limit(limit)
|
|
513
|
+
.all()
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
@classmethod
|
|
517
|
+
def revert_orphaned(cls: Type[Task], client_id: str) -> None:
|
|
518
|
+
"""Revert orphaned tasks from an evicted client to un-scheduled state."""
|
|
519
|
+
while tasks := cls.select_orphaned(client_id, 100):
|
|
520
|
+
cls.revert_all([task.id for task in tasks])
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def latest_server(cls: Type[Client]) -> Optional[str]:
|
|
524
|
+
"""Unique ID of most recent active server (if reusing database)."""
|
|
525
|
+
if records := (
|
|
526
|
+
cls.query(cls.server_id)
|
|
527
|
+
.filter(cls.schedule_time.isnot(None))
|
|
528
|
+
.order_by(func.max(cls.schedule_time).desc())
|
|
529
|
+
.group_by(cls.server_id)
|
|
530
|
+
.first()
|
|
531
|
+
):
|
|
532
|
+
return records[0]
|
|
533
|
+
else:
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def effective_rate_by_client(cls: Type[Task]) -> Optional[Dict[str, float]]:
|
|
538
|
+
"""Effective completion rate in tasks per second by client."""
|
|
539
|
+
if server_id := cls.latest_server():
|
|
540
|
+
return {id: 1 / dt.total_seconds() for id, dt in (
|
|
541
|
+
cls.query(
|
|
542
|
+
cls.client_id,
|
|
543
|
+
(func.max(cls.completion_time) - func.min(cls.start_time)) / func.count(cls.id)
|
|
544
|
+
)
|
|
545
|
+
.join(Client, Task.client_id == Client.id)
|
|
546
|
+
.filter(cls.server_id == server_id)
|
|
547
|
+
.filter(cls.completion_time.isnot(None))
|
|
548
|
+
.filter(Client.disconnected_at == None) # noqa: comparison to None
|
|
549
|
+
.group_by(cls.client_id)
|
|
550
|
+
.all()
|
|
551
|
+
)}
|
|
552
|
+
else:
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
@classmethod
|
|
556
|
+
def effective_rate(cls: Type[Task]) -> Optional[float]:
|
|
557
|
+
"""Effective completion rate in tasks per second."""
|
|
558
|
+
if by_client := cls.effective_rate_by_client():
|
|
559
|
+
return sum(by_client.values())
|
|
560
|
+
else:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
def avg_duration(cls: Type[Task]) -> Optional[float]:
|
|
565
|
+
"""Average task duration by active clients."""
|
|
566
|
+
if server_id := cls.latest_server():
|
|
567
|
+
if duration := (
|
|
568
|
+
cls.query(func.avg(cls.duration))
|
|
569
|
+
.join(Client, Task.client_id == Client.id)
|
|
570
|
+
.filter(cls.server_id == server_id)
|
|
571
|
+
.filter(cls.duration.isnot(None))
|
|
572
|
+
.filter(Client.disconnected_at == None) # noqa: comparison to None
|
|
573
|
+
.one()[0]
|
|
574
|
+
):
|
|
575
|
+
return float(duration)
|
|
576
|
+
else:
|
|
577
|
+
return None
|
|
578
|
+
else:
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
@classmethod
|
|
582
|
+
def time_to_completion(cls: Type[Task]) -> Optional[float]:
|
|
583
|
+
"""Estimated time in seconds until all unscheduled tasks are completed."""
|
|
584
|
+
if rate := cls.effective_rate():
|
|
585
|
+
return cls.count_remaining() / rate
|
|
586
|
+
else:
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
@classmethod
|
|
590
|
+
def task_pressure(cls: Type[Task], factor: float) -> Optional[float]:
|
|
591
|
+
"""Ratio of current ETC to relative `factor` of task duration."""
|
|
592
|
+
if avg_duration := cls.avg_duration():
|
|
593
|
+
if toc := cls.time_to_completion():
|
|
594
|
+
return toc / (factor * avg_duration)
|
|
595
|
+
else:
|
|
596
|
+
return None
|
|
597
|
+
else:
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Indices for efficient queries
|
|
602
|
+
index_scheduled = Index('task_scheduled_index', Task.schedule_time)
|
|
603
|
+
index_retried = Index('task_retries_index', Task.exit_status, Task.retried)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class Client(Entity):
|
|
607
|
+
"""Client entity within database implements client methods."""
|
|
608
|
+
|
|
609
|
+
id: Mapped[str] = mapped_column(UUID, primary_key=True, nullable=False)
|
|
610
|
+
host: Mapped[str] = mapped_column(TEXT, nullable=False)
|
|
611
|
+
|
|
612
|
+
server_id: Mapped[str] = mapped_column(UUID, nullable=False)
|
|
613
|
+
server_host: Mapped[str] = mapped_column(TEXT, nullable=False)
|
|
614
|
+
|
|
615
|
+
connected_at: Mapped[Optional[str]] = mapped_column(DATETIME, nullable=True)
|
|
616
|
+
disconnected_at: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
617
|
+
evicted: Mapped[bool] = mapped_column(BOOLEAN, nullable=False)
|
|
618
|
+
|
|
619
|
+
columns = {
|
|
620
|
+
'id': str,
|
|
621
|
+
'host': str,
|
|
622
|
+
'server_id': str,
|
|
623
|
+
'server_host': str,
|
|
624
|
+
'connected_at': datetime,
|
|
625
|
+
'disconnected_at': datetime,
|
|
626
|
+
'evicted': bool,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
class NotFound(NotFound):
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
class NotDistinct(NotDistinct):
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
class AlreadyExists(AlreadyExists):
|
|
636
|
+
pass
|
|
637
|
+
|
|
638
|
+
@classmethod
|
|
639
|
+
def from_id(cls: Type[Client], id: str, caching: bool = True) -> Client:
|
|
640
|
+
"""Look up client by unique `id`."""
|
|
641
|
+
try:
|
|
642
|
+
return cls.query(caching=caching).filter_by(id=id).one()
|
|
643
|
+
except NoResultFound as error:
|
|
644
|
+
raise cls.NotFound(f'No client with id={id}') from error
|
|
645
|
+
|
|
646
|
+
@classmethod
|
|
647
|
+
def from_heartbeat(cls: Type[Client], hb: Heartbeat) -> Client:
|
|
648
|
+
"""Initialize entity from client heartbeat message."""
|
|
649
|
+
return cls.new(id=hb.uuid, host=hb.host, connected_at=hb.time)
|
|
650
|
+
|
|
651
|
+
@classmethod
|
|
652
|
+
def new(cls: Type[Client],
|
|
653
|
+
id: str = None,
|
|
654
|
+
host: str = HOSTNAME,
|
|
655
|
+
server_id: str = INSTANCE,
|
|
656
|
+
server_host: str = HOSTNAME,
|
|
657
|
+
evicted: bool = False,
|
|
658
|
+
**other) -> Client:
|
|
659
|
+
"""Create a new client."""
|
|
660
|
+
return cls(id=(id or str(gen_uuid())), host=host,
|
|
661
|
+
server_id=server_id, server_host=server_host,
|
|
662
|
+
evicted=evicted, **other)
|
|
663
|
+
|
|
664
|
+
@classmethod
|
|
665
|
+
def count_connected(cls: Type[Client]) -> int:
|
|
666
|
+
"""Count active clients."""
|
|
667
|
+
if server_id := Task.latest_server():
|
|
668
|
+
return cls.query().filter_by(server_id=server_id, disconnected_at=None).count()
|
|
669
|
+
else:
|
|
670
|
+
return 0
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# Indices for efficient queries
|
|
674
|
+
index_client_disconnect = Index('client_disconnected_at', Client.disconnected_at)
|