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.
@@ -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)