analogcloud-common 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/PKG-INFO +1 -1
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/pyproject.toml +1 -1
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/api/v1.py +37 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/celery.py +3 -0
- analogcloud_common-0.2.1/src/anabrid/cloud/storage/metrics.py +160 -0
- analogcloud_common-0.2.1/src/anabrid/cloud/version.py +89 -0
- analogcloud_common-0.2.1/tests/test_storage_metrics.py +109 -0
- analogcloud_common-0.2.1/tests/test_version.py +68 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/README.md +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/generate.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.workers-compile-remote.yaml.j2 +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.workers.yaml.j2 +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.yaml.j2 +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/devices.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/api/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/auth.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/config.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/db.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/enums.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/backend.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/blob_handle.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/log_handle.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/session.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/types.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/util/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/util/config.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/__init__.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/conftest.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_blob_storage.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_deploy_generate.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_device_manager.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_models.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_priority.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_storage_backend.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_storage_backend_integration.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_create.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_delete.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_list.py +0 -0
- {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_mark_cancelled.py +0 -0
|
@@ -30,6 +30,43 @@ class SystemStatusResponse(BaseModel):
|
|
|
30
30
|
uptime: int
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
class ComponentVersion(BaseModel):
|
|
34
|
+
"""Build identity of one running component (server or worker)."""
|
|
35
|
+
|
|
36
|
+
component: str
|
|
37
|
+
version: str
|
|
38
|
+
git_sha: str | None = None
|
|
39
|
+
git_tag: str | None = None
|
|
40
|
+
environment: str | None = None
|
|
41
|
+
hostname: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WorkerVersion(ComponentVersion):
|
|
45
|
+
"""Build identity of one worker process, as reported over Celery remote control.
|
|
46
|
+
|
|
47
|
+
``dependencies`` carries the installed versions of the worker's notable
|
|
48
|
+
third-party packages (the compiler and the device library).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
worker_type: str | None = None
|
|
52
|
+
queue: str | None = None
|
|
53
|
+
dependencies: dict[str, str | None] = Field(default_factory=dict)
|
|
54
|
+
error: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SystemVersionResponse(BaseModel):
|
|
58
|
+
"""GET /api/v1/system/version response.
|
|
59
|
+
|
|
60
|
+
``workers`` lists every worker that answered the broadcast within the
|
|
61
|
+
timeout; ``workers_reachable`` is ``False`` when the broker could not be
|
|
62
|
+
asked at all, which is different from no worker answering.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
server: ComponentVersion
|
|
66
|
+
workers: list[WorkerVersion]
|
|
67
|
+
workers_reachable: bool
|
|
68
|
+
|
|
69
|
+
|
|
33
70
|
class DeviceResponse(BaseModel):
|
|
34
71
|
"""Device information for GET /api/v1/devices responses.
|
|
35
72
|
|
|
@@ -5,6 +5,9 @@ COMPILE_QUEUE = "compilation"
|
|
|
5
5
|
RUN_TASK_NAME = "run"
|
|
6
6
|
# Run queue is the device name (dynamic, no constant).
|
|
7
7
|
|
|
8
|
+
# Celery remote-control command every worker answers with its build identity.
|
|
9
|
+
WORKER_VERSION_COMMAND = "analogcloud_version"
|
|
10
|
+
|
|
8
11
|
RABBIT_MAX_PRIORITY = 10
|
|
9
12
|
RABBIT_DEFAULT_PRIORITY = 1
|
|
10
13
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Aggregate task statistics for the metrics endpoint.
|
|
2
|
+
|
|
3
|
+
Everything here is derived from the ``task_states`` history table, which
|
|
4
|
+
records one timestamped row per state transition. Waiting time is the
|
|
5
|
+
distance from a task's QUEUED row to its first INPROGRESS row; working
|
|
6
|
+
time is the distance from that INPROGRESS row to the task's last terminal
|
|
7
|
+
row. Totals are cumulative over the whole table, which makes them
|
|
8
|
+
monotonic as long as history is kept and lets a scraper take rates.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import and_, extract, func, select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from ..models.db import (
|
|
19
|
+
RunTask,
|
|
20
|
+
Task,
|
|
21
|
+
TaskState,
|
|
22
|
+
latest_task_state_subquery,
|
|
23
|
+
queued_task_state_subquery,
|
|
24
|
+
)
|
|
25
|
+
from ..models.enums import TaskStateEnum
|
|
26
|
+
|
|
27
|
+
ACTIVE_STATES: tuple[TaskStateEnum, ...] = (
|
|
28
|
+
TaskStateEnum.QUEUED,
|
|
29
|
+
TaskStateEnum.INPROGRESS,
|
|
30
|
+
)
|
|
31
|
+
TERMINAL_STATES: tuple[TaskStateEnum, ...] = (
|
|
32
|
+
TaskStateEnum.SUCCEEDED,
|
|
33
|
+
TaskStateEnum.FAILED,
|
|
34
|
+
TaskStateEnum.CANCELLED,
|
|
35
|
+
)
|
|
36
|
+
TASK_TYPES: tuple[str, ...] = ("compile", "run")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class DurationStats:
|
|
41
|
+
"""Count and summed seconds of one group of durations."""
|
|
42
|
+
|
|
43
|
+
count: int
|
|
44
|
+
total_seconds: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TaskStatistics:
|
|
49
|
+
"""Snapshot of task counts and durations, keyed by label tuples.
|
|
50
|
+
|
|
51
|
+
``device`` is the run task's device name, or ``""`` for compile tasks.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
current: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
55
|
+
"""``(type, state)`` -> number of tasks currently in that active state."""
|
|
56
|
+
|
|
57
|
+
finished: dict[tuple[str, str], int] = field(default_factory=dict)
|
|
58
|
+
"""``(type, state)`` -> number of terminal transitions recorded."""
|
|
59
|
+
|
|
60
|
+
wait: dict[tuple[str, str], DurationStats] = field(default_factory=dict)
|
|
61
|
+
"""``(type, device)`` -> QUEUED to INPROGRESS durations."""
|
|
62
|
+
|
|
63
|
+
work: dict[tuple[str, str, str], DurationStats] = field(default_factory=dict)
|
|
64
|
+
"""``(type, device, terminal state)`` -> INPROGRESS to terminal durations."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _state_value(state: object) -> str:
|
|
68
|
+
return state.value if isinstance(state, TaskStateEnum) else str(state)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def collect_task_statistics(session: AsyncSession) -> TaskStatistics:
|
|
72
|
+
"""Run the aggregate queries and return a :class:`TaskStatistics`."""
|
|
73
|
+
stats = TaskStatistics()
|
|
74
|
+
latest = latest_task_state_subquery()
|
|
75
|
+
# The run_tasks table itself rather than the RunTask entity: the entity
|
|
76
|
+
# shares the tasks table through joined inheritance, and joining it to
|
|
77
|
+
# Task makes SQLAlchemy alias it, which the GROUP BY cannot follow.
|
|
78
|
+
run_tasks = RunTask.__table__
|
|
79
|
+
device = func.coalesce(run_tasks.c.device_name, "")
|
|
80
|
+
|
|
81
|
+
current_stmt = (
|
|
82
|
+
select(Task.type, latest.c.state, func.count())
|
|
83
|
+
.join(latest, latest.c.task_id == Task.id)
|
|
84
|
+
.where(latest.c.rn == 1, latest.c.state.in_(ACTIVE_STATES))
|
|
85
|
+
.group_by(Task.type, latest.c.state)
|
|
86
|
+
)
|
|
87
|
+
for task_type, state, count in (await session.execute(current_stmt)).all():
|
|
88
|
+
stats.current[(task_type, _state_value(state))] = int(count)
|
|
89
|
+
|
|
90
|
+
finished_stmt = (
|
|
91
|
+
select(Task.type, TaskState.state, func.count())
|
|
92
|
+
.join(TaskState, TaskState.task_id == Task.id)
|
|
93
|
+
.where(TaskState.state.in_(TERMINAL_STATES))
|
|
94
|
+
.group_by(Task.type, TaskState.state)
|
|
95
|
+
)
|
|
96
|
+
for task_type, state, count in (await session.execute(finished_stmt)).all():
|
|
97
|
+
stats.finished[(task_type, _state_value(state))] = int(count)
|
|
98
|
+
|
|
99
|
+
queued = queued_task_state_subquery()
|
|
100
|
+
started = (
|
|
101
|
+
select(
|
|
102
|
+
TaskState.task_id.label("task_id"),
|
|
103
|
+
func.min(TaskState.timestamp).label("started_at"),
|
|
104
|
+
)
|
|
105
|
+
.where(TaskState.state == TaskStateEnum.INPROGRESS)
|
|
106
|
+
.group_by(TaskState.task_id)
|
|
107
|
+
.subquery("started_states")
|
|
108
|
+
)
|
|
109
|
+
ended = (
|
|
110
|
+
select(
|
|
111
|
+
TaskState.task_id.label("task_id"),
|
|
112
|
+
func.max(TaskState.timestamp).label("ended_at"),
|
|
113
|
+
)
|
|
114
|
+
.where(TaskState.state.in_(TERMINAL_STATES))
|
|
115
|
+
.group_by(TaskState.task_id)
|
|
116
|
+
.subquery("ended_states")
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
wait_stmt = (
|
|
120
|
+
select(
|
|
121
|
+
Task.type,
|
|
122
|
+
device,
|
|
123
|
+
func.count(),
|
|
124
|
+
func.coalesce(
|
|
125
|
+
func.sum(extract("epoch", started.c.started_at - queued.c.created_at)),
|
|
126
|
+
0.0,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
.join(queued, queued.c.task_id == Task.id)
|
|
130
|
+
.join(started, started.c.task_id == Task.id)
|
|
131
|
+
.outerjoin(run_tasks, run_tasks.c.id == Task.id)
|
|
132
|
+
.group_by(Task.type, device)
|
|
133
|
+
)
|
|
134
|
+
for task_type, dev, count, total in (await session.execute(wait_stmt)).all():
|
|
135
|
+
stats.wait[(task_type, dev)] = DurationStats(int(count), float(total))
|
|
136
|
+
|
|
137
|
+
work_stmt = (
|
|
138
|
+
select(
|
|
139
|
+
Task.type,
|
|
140
|
+
device,
|
|
141
|
+
latest.c.state,
|
|
142
|
+
func.count(),
|
|
143
|
+
func.coalesce(
|
|
144
|
+
func.sum(extract("epoch", ended.c.ended_at - started.c.started_at)),
|
|
145
|
+
0.0,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
.join(started, started.c.task_id == Task.id)
|
|
149
|
+
.join(ended, ended.c.task_id == Task.id)
|
|
150
|
+
.join(latest, and_(latest.c.task_id == Task.id, latest.c.rn == 1))
|
|
151
|
+
.outerjoin(run_tasks, run_tasks.c.id == Task.id)
|
|
152
|
+
.where(latest.c.state.in_(TERMINAL_STATES))
|
|
153
|
+
.group_by(Task.type, device, latest.c.state)
|
|
154
|
+
)
|
|
155
|
+
for task_type, dev, state, count, total in (await session.execute(work_stmt)).all():
|
|
156
|
+
stats.work[(task_type, dev, _state_value(state))] = DurationStats(
|
|
157
|
+
int(count), float(total)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return stats
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Build identity of a running component: version, git revision, environment.
|
|
2
|
+
|
|
3
|
+
The package version is stamped at build time (``X.Y.Z`` on a release tag,
|
|
4
|
+
``X.Y.Z.devN+g<sha>`` otherwise). The git revision and tag are baked into
|
|
5
|
+
the container images as ``ANALOGCLOUD_GIT_SHA`` / ``ANALOGCLOUD_GIT_TAG``;
|
|
6
|
+
the deployment names itself through ``ANALOGCLOUD_ENVIRONMENT``. When an
|
|
7
|
+
environment variable is absent, what can be derived from the version
|
|
8
|
+
string is derived, and the rest is reported as unknown (``None``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from importlib import metadata
|
|
18
|
+
|
|
19
|
+
_LOCAL_SHA = re.compile(r"\+g([0-9a-f]{6,40})")
|
|
20
|
+
_RELEASE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class BuildInfo:
|
|
25
|
+
"""Identity of one running component."""
|
|
26
|
+
|
|
27
|
+
component: str
|
|
28
|
+
version: str
|
|
29
|
+
git_sha: str | None
|
|
30
|
+
git_tag: str | None
|
|
31
|
+
environment: str | None
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> dict[str, str | None]:
|
|
34
|
+
"""Return the fields as a plain dict (JSON- and label-friendly)."""
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def distribution_version(
|
|
39
|
+
distribution: str,
|
|
40
|
+
*,
|
|
41
|
+
version_lookup: Callable[[str], str] = metadata.version,
|
|
42
|
+
) -> str | None:
|
|
43
|
+
"""Return the installed version of ``distribution`` or ``None`` if absent."""
|
|
44
|
+
try:
|
|
45
|
+
return version_lookup(distribution)
|
|
46
|
+
except metadata.PackageNotFoundError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_info(
|
|
51
|
+
component: str,
|
|
52
|
+
*,
|
|
53
|
+
distribution: str | None = None,
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
version_lookup: Callable[[str], str] = metadata.version,
|
|
56
|
+
) -> BuildInfo:
|
|
57
|
+
"""Return the :class:`BuildInfo` of ``component``.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
component: Short component name (``server``, ``worker``).
|
|
61
|
+
distribution: Installed distribution to read the version from;
|
|
62
|
+
defaults to ``analogcloud-<component>``.
|
|
63
|
+
env: Environment mapping; defaults to ``os.environ``.
|
|
64
|
+
version_lookup: ``importlib.metadata.version`` or a stand-in.
|
|
65
|
+
"""
|
|
66
|
+
env = os.environ if env is None else env
|
|
67
|
+
version = (
|
|
68
|
+
distribution_version(
|
|
69
|
+
distribution or f"analogcloud-{component}", version_lookup=version_lookup
|
|
70
|
+
)
|
|
71
|
+
or "unknown"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
git_sha = env.get("ANALOGCLOUD_GIT_SHA") or None
|
|
75
|
+
if git_sha is None:
|
|
76
|
+
match = _LOCAL_SHA.search(version)
|
|
77
|
+
git_sha = match.group(1) if match else None
|
|
78
|
+
|
|
79
|
+
git_tag = env.get("ANALOGCLOUD_GIT_TAG") or None
|
|
80
|
+
if git_tag is None and _RELEASE.match(version):
|
|
81
|
+
git_tag = f"v{version}"
|
|
82
|
+
|
|
83
|
+
return BuildInfo(
|
|
84
|
+
component=component,
|
|
85
|
+
version=version,
|
|
86
|
+
git_sha=git_sha,
|
|
87
|
+
git_tag=git_tag,
|
|
88
|
+
environment=env.get("ANALOGCLOUD_ENVIRONMENT") or None,
|
|
89
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Tests for anabrid.cloud.storage.metrics.collect_task_statistics (real Postgres)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from anabrid.cloud.models.db import CompileTask, Device, RunTask, TaskState
|
|
9
|
+
from anabrid.cloud.models.enums import DeviceTypeEnum, TaskStateEnum
|
|
10
|
+
from anabrid.cloud.storage import BlobHandle
|
|
11
|
+
from anabrid.cloud.storage.metrics import DurationStats, collect_task_statistics
|
|
12
|
+
|
|
13
|
+
T0 = datetime(2026, 1, 1, 12, 0, 0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _compile_task() -> CompileTask:
|
|
17
|
+
return CompileTask(
|
|
18
|
+
user_id="u", ode_source="x' = -x", backend="redac", k0=None, module=BlobHandle(uuid4())
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_task(device: str) -> RunTask:
|
|
23
|
+
return RunTask(user_id="u", device_name=device, module=BlobHandle(uuid4()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _insert(session_factory, task, transitions):
|
|
27
|
+
"""Insert ``task`` with one TaskState per ``(state, seconds_after_T0)``."""
|
|
28
|
+
async with session_factory() as session:
|
|
29
|
+
session.add(task)
|
|
30
|
+
await session.flush()
|
|
31
|
+
for state, offset in transitions:
|
|
32
|
+
session.add(
|
|
33
|
+
TaskState(task_id=task.id, state=state, timestamp=T0 + timedelta(seconds=offset))
|
|
34
|
+
)
|
|
35
|
+
await session.commit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _device(session_factory, name="lucidac0"):
|
|
39
|
+
async with session_factory() as session:
|
|
40
|
+
session.add(Device(name=name, type=DeviceTypeEnum.LUCIDAC, host="10.0.0.5", available=True))
|
|
41
|
+
await session.commit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_empty_database(session_factory):
|
|
46
|
+
"""No tasks: every mapping is empty and nothing raises."""
|
|
47
|
+
async with session_factory() as session:
|
|
48
|
+
stats = await collect_task_statistics(session)
|
|
49
|
+
assert stats.current == {} and stats.finished == {}
|
|
50
|
+
assert stats.wait == {} and stats.work == {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_current_counts_latest_state_only(session_factory):
|
|
55
|
+
"""A task is counted once, under its latest state."""
|
|
56
|
+
Q, P = TaskStateEnum.QUEUED, TaskStateEnum.INPROGRESS
|
|
57
|
+
await _insert(session_factory, _compile_task(), [(Q, 0)])
|
|
58
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 5)])
|
|
59
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 5), (TaskStateEnum.SUCCEEDED, 9)])
|
|
60
|
+
async with session_factory() as session:
|
|
61
|
+
stats = await collect_task_statistics(session)
|
|
62
|
+
assert stats.current == {("compile", "queued"): 1, ("compile", "inprogress"): 1}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_finished_counts_by_type_and_outcome(session_factory):
|
|
67
|
+
"""Terminal transitions are counted per task type and outcome."""
|
|
68
|
+
await _device(session_factory)
|
|
69
|
+
Q, P = TaskStateEnum.QUEUED, TaskStateEnum.INPROGRESS
|
|
70
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 1), (TaskStateEnum.SUCCEEDED, 2)])
|
|
71
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 1), (TaskStateEnum.FAILED, 2)])
|
|
72
|
+
await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (TaskStateEnum.CANCELLED, 1)])
|
|
73
|
+
await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 1), (TaskStateEnum.SUCCEEDED, 4)])
|
|
74
|
+
async with session_factory() as session:
|
|
75
|
+
stats = await collect_task_statistics(session)
|
|
76
|
+
assert stats.finished == {
|
|
77
|
+
("compile", "succeeded"): 1,
|
|
78
|
+
("compile", "failed"): 1,
|
|
79
|
+
("run", "cancelled"): 1,
|
|
80
|
+
("run", "succeeded"): 1,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.asyncio
|
|
85
|
+
async def test_wait_and_work_durations(session_factory):
|
|
86
|
+
"""Wait is QUEUED->INPROGRESS, work is INPROGRESS->terminal; run tasks carry the device."""
|
|
87
|
+
await _device(session_factory)
|
|
88
|
+
Q, P, S, F = (
|
|
89
|
+
TaskStateEnum.QUEUED,
|
|
90
|
+
TaskStateEnum.INPROGRESS,
|
|
91
|
+
TaskStateEnum.SUCCEEDED,
|
|
92
|
+
TaskStateEnum.FAILED,
|
|
93
|
+
)
|
|
94
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 2), (S, 12)]) # wait 2, work 10
|
|
95
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (P, 4), (F, 5)]) # wait 4, work 1
|
|
96
|
+
await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 30), (S, 31.5)])
|
|
97
|
+
await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 1)]) # still running
|
|
98
|
+
await _insert(session_factory, _compile_task(), [(Q, 0), (TaskStateEnum.CANCELLED, 3)]) # never started
|
|
99
|
+
async with session_factory() as session:
|
|
100
|
+
stats = await collect_task_statistics(session)
|
|
101
|
+
assert stats.wait == {
|
|
102
|
+
("compile", ""): DurationStats(2, 6.0),
|
|
103
|
+
("run", "lucidac0"): DurationStats(2, 31.0),
|
|
104
|
+
}
|
|
105
|
+
assert stats.work == {
|
|
106
|
+
("compile", "", "succeeded"): DurationStats(1, 10.0),
|
|
107
|
+
("compile", "", "failed"): DurationStats(1, 1.0),
|
|
108
|
+
("run", "lucidac0", "succeeded"): DurationStats(1, 1.5),
|
|
109
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tests for anabrid.cloud.version.build_info."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from anabrid.cloud.version import BuildInfo, build_info, distribution_version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _lookup(version: str):
|
|
11
|
+
def lookup(name: str) -> str:
|
|
12
|
+
assert name == "analogcloud-server"
|
|
13
|
+
return version
|
|
14
|
+
|
|
15
|
+
return lookup
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_release_version_derives_tag_and_has_no_sha():
|
|
19
|
+
"""A plain X.Y.Z version is a release: tag is vX.Y.Z, sha unknown."""
|
|
20
|
+
info = build_info("server", env={}, version_lookup=_lookup("0.2.0"))
|
|
21
|
+
assert info == BuildInfo("server", "0.2.0", None, "v0.2.0", None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dev_version_parses_sha_and_has_no_tag():
|
|
25
|
+
"""The +g<sha> local segment of a dev version yields the short sha."""
|
|
26
|
+
info = build_info("server", env={}, version_lookup=_lookup("0.2.1.dev17+g1234abcd"))
|
|
27
|
+
assert info.git_sha == "1234abcd"
|
|
28
|
+
assert info.git_tag is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_environment_overrides_win():
|
|
32
|
+
"""Explicit ANALOGCLOUD_GIT_* / ENVIRONMENT values take precedence."""
|
|
33
|
+
env = {
|
|
34
|
+
"ANALOGCLOUD_GIT_SHA": "deadbeef",
|
|
35
|
+
"ANALOGCLOUD_GIT_TAG": "v9.9.9",
|
|
36
|
+
"ANALOGCLOUD_ENVIRONMENT": "prod",
|
|
37
|
+
}
|
|
38
|
+
info = build_info("server", env=env, version_lookup=_lookup("0.2.1.dev17+g1234abcd"))
|
|
39
|
+
assert (info.git_sha, info.git_tag, info.environment) == ("deadbeef", "v9.9.9", "prod")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_empty_environment_values_count_as_absent():
|
|
43
|
+
"""Empty strings (unset Docker build args) do not override derived values."""
|
|
44
|
+
env = {"ANALOGCLOUD_GIT_SHA": "", "ANALOGCLOUD_GIT_TAG": "", "ANALOGCLOUD_ENVIRONMENT": ""}
|
|
45
|
+
info = build_info("server", env=env, version_lookup=_lookup("0.2.0"))
|
|
46
|
+
assert info == BuildInfo("server", "0.2.0", None, "v0.2.0", None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_unknown_distribution():
|
|
50
|
+
"""A missing distribution reports version 'unknown' instead of raising."""
|
|
51
|
+
|
|
52
|
+
def missing(name: str) -> str:
|
|
53
|
+
raise metadata.PackageNotFoundError(name)
|
|
54
|
+
|
|
55
|
+
info = build_info("server", env={}, version_lookup=missing)
|
|
56
|
+
assert info.version == "unknown"
|
|
57
|
+
assert distribution_version("nope", version_lookup=missing) is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_as_dict_roundtrip():
|
|
61
|
+
"""as_dict exposes exactly the dataclass fields."""
|
|
62
|
+
info = build_info("server", env={}, version_lookup=_lookup("0.2.0"))
|
|
63
|
+
assert set(info.as_dict()) == {"component", "version", "git_sha", "git_tag", "environment"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_real_distribution_is_installed():
|
|
67
|
+
"""The common package itself is resolvable through importlib.metadata."""
|
|
68
|
+
assert build_info("common", env={}).version != "unknown"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/blob_handle.py
RENAMED
|
File without changes
|
{analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/log_handle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_storage_backend_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|