scalio-platform 0.1.0__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.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalio-platform
3
+ Version: 0.1.0
4
+ Summary: Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: pymongo>=4.6
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: fastapi>=0.110; extra == "dev"
13
+ Requires-Dist: httpx>=0.27; extra == "dev"
14
+ Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+
17
+ # scalio-platform
18
+
19
+ Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
20
+ Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
21
+ scalio-docs). Nothing in this package is secret.
22
+
23
+ | Module | What it does |
24
+ | --- | --- |
25
+ | `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
26
+ | `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
27
+ | `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
28
+ | `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
29
+ | `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
30
+
31
+ ## Use in an app
32
+
33
+ ```
34
+ scalio-platform==0.1.0 # backend/requirements.txt, exact version
35
+ ```
36
+
37
+ ```python
38
+ from scalio_platform import error_report, health, job_lease
39
+ from scalio_platform.migrations import BASELINE, run_migrations
40
+
41
+ MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
42
+
43
+ async def startup():
44
+ await run_migrations(db, MIGRATIONS) # before the background loops
45
+ for name, loop in LOOPS.items():
46
+ asyncio.create_task(job_lease.run_exclusive(db, name, loop))
47
+
48
+ error_report.install(app, "citadel", build_info)
49
+ code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
50
+ ```
51
+
52
+ A migration step must be safe to run twice: two instances can start at the same moment.
53
+
54
+ ## Develop and release
55
+
56
+ - `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
57
+ - Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
58
+ Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
59
+ - A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
@@ -0,0 +1,43 @@
1
+ # scalio-platform
2
+
3
+ Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
4
+ Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
5
+ scalio-docs). Nothing in this package is secret.
6
+
7
+ | Module | What it does |
8
+ | --- | --- |
9
+ | `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
10
+ | `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
11
+ | `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
12
+ | `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
13
+ | `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
14
+
15
+ ## Use in an app
16
+
17
+ ```
18
+ scalio-platform==0.1.0 # backend/requirements.txt, exact version
19
+ ```
20
+
21
+ ```python
22
+ from scalio_platform import error_report, health, job_lease
23
+ from scalio_platform.migrations import BASELINE, run_migrations
24
+
25
+ MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
26
+
27
+ async def startup():
28
+ await run_migrations(db, MIGRATIONS) # before the background loops
29
+ for name, loop in LOOPS.items():
30
+ asyncio.create_task(job_lease.run_exclusive(db, name, loop))
31
+
32
+ error_report.install(app, "citadel", build_info)
33
+ code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
34
+ ```
35
+
36
+ A migration step must be safe to run twice: two instances can start at the same moment.
37
+
38
+ ## Develop and release
39
+
40
+ - `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
41
+ - Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
42
+ Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
43
+ - A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scalio-platform"
7
+ version = "0.1.0"
8
+ description = "Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ dependencies = ["httpx>=0.27", "pymongo>=4.6"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27", "mongomock-motor>=0.0.29", "ruff>=0.6"]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+
23
+ [tool.ruff]
24
+ line-length = 120
25
+ target-version = "py311"
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "W", "I", "B"]
29
+ ignore = ["B008"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Shared building blocks of the Scalio apps (architecture review C2/C4, ADR 0058).
2
+
3
+ Modules: job_lease (one instance per background loop), migrations (schema version and steps), health
4
+ (GET /api/health), error_report (errors to Scalio Monitor), app_links (product switcher).
5
+ """
6
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ """Links of the product switcher: where the other Scalio apps of this installation run.
2
+
3
+ SCALIO_APP_LINKS lists them per instance, e.g.
4
+ SCALIO_APP_LINKS=nexus=https://dev.scalionexus.com,rosetta=https://dev.scaliorosetta.com
5
+ Apps without a link stay greyed out in the switcher. Module of the shared package scalio-platform (formerly a
6
+ copy in every app with the switcher).
7
+
8
+ Which Scalio functions this instance shows depends on how it runs:
9
+ SCALIO_STAGE=dev|test|demo our own instances (set in scalio-infra); empty = production
10
+ SAAS_MODE=true a SaaS customer instance (set by the provisioning engine)
11
+ A self-hosted customer sets neither, so the switcher stays hidden there (we cannot know which other apps that
12
+ customer has licensed). Its support button shows while the license maintenance runs (ADR 0022, T11): the caller
13
+ passes that in (support.py).
14
+ """
15
+ import os
16
+ from urllib.parse import urlsplit
17
+
18
+ APPS = ("nexus", "rosetta", "foundry", "citadel", "beacon", "pulse")
19
+ STAGES = ("dev", "test", "demo")
20
+ _TRUE = ("1", "true", "yes", "on")
21
+
22
+
23
+ def parse_app_links(raw: str | None) -> dict:
24
+ """Known app keys with an http(s) URL; anything else is ignored."""
25
+ links = {}
26
+ for part in (raw or "").split(","):
27
+ key, sep, url = part.partition("=")
28
+ key, url = key.strip().lower(), url.strip().rstrip("/")
29
+ if not sep or key not in APPS:
30
+ continue
31
+ parts = urlsplit(url)
32
+ if parts.scheme in ("http", "https") and parts.netloc and not parts.username and not parts.password:
33
+ links[key] = url
34
+ return links
35
+
36
+
37
+ def stage() -> str:
38
+ """dev, test or demo for our own instances, "prod" otherwise (also for unknown values)."""
39
+ value = (os.environ.get("SCALIO_STAGE") or "").strip().lower()
40
+ return value if value in STAGES else "prod"
41
+
42
+
43
+ def saas_mode() -> bool:
44
+ return (os.environ.get("SAAS_MODE") or "").strip().lower() in _TRUE
45
+
46
+
47
+ def features(self_hosted_support: bool = False) -> dict:
48
+ """Scalio functions shown by this instance. Demo shows the switcher but takes no support tickets.
49
+ self_hosted_support: a self-hosted customer's maintenance runs (decided by the caller from the license)."""
50
+ current, saas = stage(), saas_mode()
51
+ return {
52
+ "switcher": saas or current in STAGES,
53
+ "support": saas or current in ("dev", "test") or (current == "prod" and bool(self_hosted_support)),
54
+ }
55
+
56
+
57
+ def app_links(support: bool | None = None) -> dict:
58
+ """Public answer of GET /api/apps. Links only where the switcher is shown. support: the caller's answer
59
+ whether the support button shows (support.shown(): maintenance, hidden by an admin)."""
60
+ shown = features()
61
+ if support is not None:
62
+ shown["support"] = bool(support)
63
+ links = parse_app_links(os.environ.get("SCALIO_APP_LINKS")) if shown["switcher"] else {}
64
+ return {"links": links, "stage": stage(), "saas": saas_mode(), "features": shown}
@@ -0,0 +1,179 @@
1
+ """Report unexpected errors to Scalio Monitor (monitoring.scaliocloud.com, POST /ingest/error).
2
+
3
+ Module of the shared package scalio-platform (formerly a copy in every app); protocol in scalio-monitor docs/ERRORS.md.
4
+ Off unless ERROR_REPORT_URL and ERROR_REPORT_TOKEN are set, so self-hosted customers send nothing unless they
5
+ opt in (decision M4). Sent are only: type, message, stack, route template, method, status, version, commit.
6
+ Never request bodies, headers, query strings, cookies or user data; the monitor cleans the text again.
7
+
8
+ from scalio_platform import error_report
9
+ error_report.install(app, "nexus", build_info) # after app = FastAPI(...)
10
+ await error_report.report_exception(exc, kind="job", path="lifecycle_task") # background loops
11
+
12
+ The report runs in the background with a short timeout: a slow or missing monitor never slows a request.
13
+ The same error at the same place is sent at most once per minute per process.
14
+ The groups the monitor answered are kept in memory (`recent_groups()`): a support ticket lists them with a link to
15
+ the monitor (ADR 0022, T9; `support_router(..., errors=error_report.errors_for_support)`).
16
+ """
17
+ import asyncio
18
+ import logging
19
+ import os
20
+ import re
21
+ import time
22
+ import traceback
23
+ from datetime import datetime, timezone
24
+
25
+ import httpx
26
+
27
+ log = logging.getLogger("error_report")
28
+ TIMEOUT = 5.0
29
+ REPEAT_SECONDS = 60
30
+ MAX_STACK = 8000
31
+ MAX_GROUPS = 50
32
+ _FINGERPRINT = re.compile(r"^[0-9a-f]{8,64}$")
33
+ _ID = re.compile(r"^(?:\d+|[0-9a-f]{24}|[0-9a-f-]{36}|[\w-]{20,})$", re.I) # numbers, ObjectIds, UUIDs, tokens
34
+
35
+
36
+ class Reporter:
37
+ def __init__(self, app_name: str, build_info=None, url: str | None = None, token: str | None = None,
38
+ transport: httpx.AsyncBaseTransport | None = None, clock=time.monotonic):
39
+ self.app_name = app_name
40
+ self.build_info = build_info or (lambda: {})
41
+ self.url = (url if url is not None else os.environ.get("ERROR_REPORT_URL", "")).strip().rstrip("/")
42
+ self.token = (token if token is not None else os.environ.get("ERROR_REPORT_TOKEN", "")).strip()
43
+ self.transport, self.clock = transport, clock
44
+ self._client: httpx.AsyncClient | None = None
45
+ self._last: dict = {}
46
+ self._tasks: set = set()
47
+ self._groups: dict = {}
48
+
49
+ @property
50
+ def enabled(self) -> bool:
51
+ return bool(self.url and self.token)
52
+
53
+ def payload(self, exc: BaseException, kind: str, path: str = "", method: str = "",
54
+ status: int | None = None) -> dict:
55
+ try:
56
+ build = self.build_info() or {}
57
+ except Exception: # noqa: BLE001 - a broken version lookup must not hide the error
58
+ build = {}
59
+ stack = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))[-MAX_STACK:]
60
+ return {"app": self.app_name, "kind": kind, "type": type(exc).__name__, "message": str(exc)[:2000],
61
+ "stack": stack, "path": path, "method": method, "status": status,
62
+ "version": str(build.get("version") or ""), "commit": str(build.get("commit") or "")}
63
+
64
+ def _repeat(self, body: dict) -> bool:
65
+ tb = body["stack"].strip().splitlines()
66
+ key = (body["type"], body["path"], tb[-3] if len(tb) >= 3 else body["message"][:100])
67
+ now = self.clock()
68
+ if now - self._last.get(key, -REPEAT_SECONDS) < REPEAT_SECONDS:
69
+ return True
70
+ self._last[key] = now
71
+ if len(self._last) > 500:
72
+ self._last = {k: v for k, v in self._last.items() if now - v < REPEAT_SECONDS}
73
+ return False
74
+
75
+ async def send(self, body: dict) -> bool:
76
+ if self._client is None:
77
+ self._client = httpx.AsyncClient(transport=self.transport, timeout=TIMEOUT)
78
+ try:
79
+ r = await self._client.post(f"{self.url}/ingest/error", json=body,
80
+ headers={"Authorization": f"Bearer {self.token}"})
81
+ except httpx.HTTPError as exc:
82
+ log.warning("error report not sent: %s", type(exc).__name__) # never log the token
83
+ return False
84
+ if r.status_code != 200:
85
+ return False
86
+ self._remember(body, r)
87
+ return True
88
+
89
+ def _remember(self, body: dict, response: httpx.Response) -> None:
90
+ try:
91
+ fp = str((response.json() or {}).get("group") or "")
92
+ except ValueError:
93
+ return
94
+ if not _FINGERPRINT.match(fp):
95
+ return
96
+ old = self._groups.pop(fp, {})
97
+ self._groups[fp] = {"fingerprint": fp, "type": body["type"], "route": body["path"] or body["kind"],
98
+ "time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
99
+ "count": old.get("count", 0) + 1, "monitor": self.url}
100
+ while len(self._groups) > MAX_GROUPS:
101
+ self._groups.pop(next(iter(self._groups)))
102
+
103
+ def recent_groups(self, limit: int = 10) -> list:
104
+ """Last error groups this process reported, oldest first (no messages, no stack)."""
105
+ return [dict(g) for g in list(self._groups.values())[-limit:]]
106
+
107
+ def submit(self, exc: BaseException, kind: str = "backend", path: str = "", method: str = "",
108
+ status: int | None = None):
109
+ """Queue a report in the background. Returns the task, or None when off or repeated."""
110
+ if not self.enabled:
111
+ return None
112
+ body = self.payload(exc, kind, path, method, status)
113
+ if self._repeat(body):
114
+ return None
115
+ task = asyncio.get_running_loop().create_task(self.send(body))
116
+ self._tasks.add(task)
117
+ task.add_done_callback(self._tasks.discard)
118
+ return task
119
+
120
+ async def drain(self):
121
+ """Wait for queued reports (tests, shutdown)."""
122
+ if self._tasks:
123
+ await asyncio.gather(*list(self._tasks), return_exceptions=True)
124
+
125
+
126
+ def route_template(scope: dict) -> str:
127
+ route = scope.get("route")
128
+ if route is not None and getattr(route, "path", None):
129
+ return route.path
130
+ parts = [("{id}" if _ID.match(p) else p) for p in (scope.get("path") or "").split("/")]
131
+ return "/".join(parts)[:300]
132
+
133
+
134
+ class ErrorReportMiddleware:
135
+ """Pure ASGI middleware: reports exceptions no handler caught (the 500s), then lets them go on."""
136
+
137
+ def __init__(self, app, reporter: Reporter):
138
+ self.app, self.reporter = app, reporter
139
+
140
+ async def __call__(self, scope, receive, send):
141
+ if scope["type"] != "http":
142
+ return await self.app(scope, receive, send)
143
+ try:
144
+ await self.app(scope, receive, send)
145
+ except Exception as exc:
146
+ try:
147
+ self.reporter.submit(exc, "backend", route_template(scope), scope.get("method", ""), 500)
148
+ except Exception: # noqa: BLE001 - reporting must never change the answer
149
+ log.warning("error report failed")
150
+ raise
151
+
152
+
153
+ _reporter: Reporter | None = None
154
+
155
+
156
+ def install(app, app_name: str, build_info=None, **kw) -> Reporter:
157
+ """Report the unhandled errors of this FastAPI app. Off without ERROR_REPORT_URL/ERROR_REPORT_TOKEN."""
158
+ global _reporter
159
+ _reporter = Reporter(app_name, build_info, **kw)
160
+ app.add_middleware(ErrorReportMiddleware, reporter=_reporter)
161
+ if _reporter.enabled:
162
+ log.info("error reports to %s are on", _reporter.url)
163
+ return _reporter
164
+
165
+
166
+ async def report_exception(exc: BaseException, kind: str = "job", path: str = "") -> None:
167
+ """For background loops: report an exception that would otherwise only reach the log."""
168
+ if _reporter is not None:
169
+ _reporter.submit(exc, kind, path)
170
+
171
+
172
+ def recent_groups(limit: int = 10) -> list:
173
+ """Last error groups reported by this process: fingerprint, type, route, time, count, monitor."""
174
+ return _reporter.recent_groups(limit) if _reporter is not None else []
175
+
176
+
177
+ async def errors_for_support(_user=None) -> list:
178
+ """For the support ticket's diagnostic block (scalio-connect support_router errors=...)."""
179
+ return recent_groups()
@@ -0,0 +1,62 @@
1
+ """GET /api/health: the same answer in every Scalio app, read by the monitoring dashboard
2
+ (monitoring.scaliocloud.com, concept https://claude.ai/artifact/Ls47WPEtKL2LQfEiMroJFH).
3
+
4
+ {"status": "ok" | "degraded" | "down", "version": "...", "commit": "...", "db": "ok" | "error" | "none",
5
+ "jobs": {"<loop>": {"ok": true, "last_seen": "<ISO time>"}}}
6
+
7
+ HTTP 200 for ok and degraded, 503 for down (database not answering). degraded = a background loop that should
8
+ run has no valid lease (job_leases, see job_lease.py), so nobody runs it. No login, no secrets, no counts of
9
+ customers or users, no host names. db "none" = the app runs without a database (cockpit without MONGO_URL).
10
+ Module of the shared package scalio-platform (formerly a copy in every app).
11
+ """
12
+ import asyncio
13
+ from datetime import UTC, datetime
14
+
15
+ DB_TIMEOUT = 2.0 # seconds; the dashboard checks every minute and must not hang on a dead database
16
+
17
+
18
+ def _aware(value):
19
+ if isinstance(value, datetime) and value.tzinfo is None:
20
+ return value.replace(tzinfo=UTC) # pymongo returns naive UTC
21
+ return value if isinstance(value, datetime) else None
22
+
23
+
24
+ async def _db_ok(db, timeout: float) -> bool:
25
+ try:
26
+ await asyncio.wait_for(db.command("ping"), timeout)
27
+ return True
28
+ except Exception: # noqa: BLE001 - any failure means "down" for the dashboard
29
+ return False
30
+
31
+
32
+ async def _lease_jobs(db, expected, now) -> dict:
33
+ found = {}
34
+ async for d in db["job_leases"].find({"_id": {"$in": list(expected)}}, {"until": 1, "renewedAt": 1}):
35
+ found[d["_id"]] = d
36
+ jobs = {}
37
+ for name in sorted(expected):
38
+ d = found.get(name) or {}
39
+ until, seen = _aware(d.get("until")), _aware(d.get("renewedAt"))
40
+ jobs[name] = {"ok": bool(until and until >= now), "last_seen": seen.isoformat() if seen else None}
41
+ return jobs
42
+
43
+
44
+ async def health_report(db, build: dict, lease_jobs=(), timeout: float = DB_TIMEOUT, now=None):
45
+ """(HTTP status, body) for GET /api/health. `lease_jobs` = names of the loops that run under a job lease."""
46
+ now = now or datetime.now(UTC)
47
+ body = {"status": "ok", "version": build.get("version", ""), "commit": build.get("commit", ""),
48
+ "db": "ok", "jobs": {}}
49
+ if db is None:
50
+ body["db"] = "none"
51
+ return 200, body
52
+ if not await _db_ok(db, timeout):
53
+ body.update(status="down", db="error")
54
+ return 503, body
55
+ if lease_jobs:
56
+ try:
57
+ body["jobs"] = await asyncio.wait_for(_lease_jobs(db, lease_jobs, now), timeout)
58
+ except Exception: # noqa: BLE001
59
+ body["jobs"] = {}
60
+ if any(not j["ok"] for j in body["jobs"].values()):
61
+ body["status"] = "degraded"
62
+ return 200, body
@@ -0,0 +1,99 @@
1
+ """Run each background loop on one backend instance only.
2
+
3
+ During an update two backends or workers can run side by side for a moment (compose recreate, Container Apps
4
+ revision swap). Without a lock both would run the same job twice (act in a tenant, bill, send mail). Each loop
5
+ therefore runs only while its instance holds a lease in
6
+ db.job_leases ({_id: loop name, owner, until}). The holder renews it every RENEW seconds; another instance
7
+ waits and takes over once the lease has expired (TTL seconds after the holder stopped renewing). A graceful
8
+ stop releases the lease at once.
9
+
10
+ Architecture review C1: https://claude.ai/artifact/2GWNZiXnnFVo5RTRnwm1Zu
11
+ """
12
+ import asyncio
13
+ import logging
14
+ import os
15
+ import socket
16
+ import uuid
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ from pymongo import ReturnDocument
20
+ from pymongo.errors import DuplicateKeyError
21
+
22
+ logger = logging.getLogger("scalio_platform.lease")
23
+
24
+ COLLECTION = "job_leases"
25
+ OWNER = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
26
+ TTL = 60 # seconds a lease stays valid without renewal
27
+ RENEW = 20 # holder renews this often
28
+ WAIT = 15 # a waiting instance retries this often
29
+
30
+
31
+ def _now():
32
+ return datetime.now(timezone.utc)
33
+
34
+
35
+ async def acquire(db, name: str, owner: str = OWNER, ttl: float = TTL) -> bool:
36
+ """Take or renew the lease `name`. True when `owner` holds it afterwards."""
37
+ now = _now()
38
+ try:
39
+ doc = await db[COLLECTION].find_one_and_update(
40
+ {"_id": name, "$or": [{"owner": owner}, {"until": {"$lt": now}}]},
41
+ {"$set": {"owner": owner, "until": now + timedelta(seconds=ttl), "renewedAt": now}},
42
+ upsert=True, return_document=ReturnDocument.AFTER)
43
+ except DuplicateKeyError: # someone else holds a valid lease
44
+ return False
45
+ return bool(doc) and doc.get("owner") == owner
46
+
47
+
48
+ async def release(db, name: str, owner: str = OWNER):
49
+ await db[COLLECTION].delete_one({"_id": name, "owner": owner})
50
+
51
+
52
+ async def run_exclusive(db, name: str, loop_factory, owner: str = OWNER,
53
+ ttl: float = TTL, renew: float = RENEW, wait: float = WAIT):
54
+ """Run `loop_factory()` only while holding the lease `name`.
55
+
56
+ Waits for the lease, starts the loop, renews the lease while the loop runs, and cancels the loop when the
57
+ lease is lost (another instance took it over). If the database cannot be reached for a renewal, the loop
58
+ keeps running until the lease it already holds would expire, so a short outage does not stop the work.
59
+ """
60
+ task = None
61
+ try:
62
+ while True:
63
+ held_until = None
64
+ while held_until is None:
65
+ try:
66
+ if await acquire(db, name, owner, ttl):
67
+ held_until = _now() + timedelta(seconds=ttl)
68
+ break
69
+ except Exception as e: # noqa: BLE001
70
+ logger.warning(f"lease {name}: acquire failed: {e}")
71
+ await asyncio.sleep(wait)
72
+
73
+ task = asyncio.create_task(loop_factory())
74
+ while True:
75
+ done, _ = await asyncio.wait({task}, timeout=renew)
76
+ if done:
77
+ return task.result() # the loop ended by itself (or raised)
78
+ try:
79
+ if await acquire(db, name, owner, ttl):
80
+ held_until = _now() + timedelta(seconds=ttl)
81
+ continue
82
+ logger.warning(f"lease {name}: taken over by another instance, stopping the loop here")
83
+ except Exception as e: # noqa: BLE001
84
+ if _now() + timedelta(seconds=renew) < held_until:
85
+ logger.warning(f"lease {name}: renewal failed, keeping the loop for now: {e}")
86
+ continue
87
+ logger.warning(f"lease {name}: renewal failed and the lease expires, stopping the loop: {e}")
88
+ task.cancel()
89
+ await asyncio.gather(task, return_exceptions=True)
90
+ task = None
91
+ break
92
+ finally:
93
+ if task is not None and not task.done():
94
+ task.cancel()
95
+ await asyncio.gather(task, return_exceptions=True)
96
+ try:
97
+ await release(db, name, owner)
98
+ except Exception as e: # noqa: BLE001
99
+ logger.info(f"lease {name}: release failed, it expires on its own: {e}")
@@ -0,0 +1,76 @@
1
+ """Schema migrations for MongoDB: ordered steps, each applied once, with the schema version stored in the database.
2
+
3
+ Taken from scalio-nexus `backend/migrations.py` (architecture review C4). The version lives in
4
+ `db.meta` ({id: "schema_version", version, appliedAt}), the same document Nexus already writes, so Nexus keeps its
5
+ history. Export/import (scalio-backup) can read it with `schema_version(db)` on both sides.
6
+
7
+ from scalio_platform.migrations import BASELINE, run_migrations
8
+
9
+ async def migrate_002_rename_field(db):
10
+ await db.items.update_many({"old": {"$exists": True}}, {"$rename": {"old": "new"}})
11
+
12
+ MIGRATIONS = BASELINE + [(2, migrate_002_rename_field)]
13
+ await run_migrations(db, MIGRATIONS) # at startup, before the background loops
14
+
15
+ Rules: versions strictly increasing, append only, never edit or reorder a past step. Every step must be safe to run
16
+ again (idempotent): during an update two instances can start at the same moment and both run it.
17
+ A database with a newer version than the image knows (an older image after a rollback) is left as it is; the
18
+ call logs a warning and returns the stored version.
19
+ """
20
+ import logging
21
+ from datetime import datetime, timezone
22
+
23
+ logger = logging.getLogger("scalio_platform.migrations")
24
+
25
+ DOC_ID = "schema_version"
26
+
27
+
28
+ async def migrate_001_baseline(db):
29
+ """Baseline: marks a database as managed by the runner. Changes nothing."""
30
+ return
31
+
32
+
33
+ BASELINE = [(1, migrate_001_baseline)]
34
+
35
+
36
+ def _checked(migrations) -> list:
37
+ steps = list(migrations)
38
+ versions = [v for v, _ in steps]
39
+ if any(not isinstance(v, int) or v < 1 for v in versions):
40
+ raise ValueError("migration versions must be positive integers")
41
+ if versions != sorted(versions) or len(set(versions)) != len(versions):
42
+ raise ValueError("migration versions must be strictly increasing (append only)")
43
+ return steps
44
+
45
+
46
+ async def schema_version(db, collection: str = "meta") -> int:
47
+ """The schema version stored in the database, 0 for a database the runner never touched."""
48
+ doc = await db[collection].find_one({"id": DOC_ID}, {"_id": 0, "version": 1}) or {}
49
+ return int(doc.get("version", 0))
50
+
51
+
52
+ async def run_migrations(db, migrations, collection: str = "meta", log: logging.Logger | None = None) -> int:
53
+ """Apply every step whose version is above the stored one, in order, storing the version after each step.
54
+ Returns the schema version afterwards. A failing step raises; the version stays at the last good step."""
55
+ log = log or logger
56
+ steps = _checked(migrations)
57
+ current = await schema_version(db, collection)
58
+ latest = steps[-1][0] if steps else 0
59
+ if current > latest:
60
+ log.warning("database schema_version %s is newer than this build knows (%s); nothing migrated",
61
+ current, latest)
62
+ return current
63
+ applied = current
64
+ for version, step in steps:
65
+ if version <= current:
66
+ continue
67
+ log.info("running DB migration %s: %s", version, getattr(step, "__name__", "step"))
68
+ await step(db)
69
+ await db[collection].update_one( # $max: a second instance that is already further never goes back
70
+ {"id": DOC_ID},
71
+ {"$max": {"version": version}, "$set": {"appliedAt": datetime.now(timezone.utc).isoformat()}},
72
+ upsert=True)
73
+ applied = version
74
+ if applied != current:
75
+ log.info("DB migrations complete, schema_version now %s", applied)
76
+ return applied
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalio-platform
3
+ Version: 0.1.0
4
+ Summary: Shared building blocks of the Scalio apps: job leases, schema migrations, health, error reports, app links
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: pymongo>=4.6
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: fastapi>=0.110; extra == "dev"
13
+ Requires-Dist: httpx>=0.27; extra == "dev"
14
+ Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+
17
+ # scalio-platform
18
+
19
+ Shared building blocks of the Scalio apps (Nexus, Compass, Citadel, Foundry, Rosetta, Pulse, Beacon, Cockpit, ID,
20
+ Monitor). Before this package each app kept its own copy of these files (architecture review C2/C4, ADR 0058 in
21
+ scalio-docs). Nothing in this package is secret.
22
+
23
+ | Module | What it does |
24
+ | --- | --- |
25
+ | `scalio_platform.job_lease` | Runs a background loop on one instance at a time (lease in `db.job_leases`). During an update two instances run side by side; without the lease both would act. |
26
+ | `scalio_platform.migrations` | Schema migrations for MongoDB: ordered steps, each applied once, version in `db.meta` (`schema_version`). |
27
+ | `scalio_platform.health` | The answer of `GET /api/health` that Scalio Monitor reads (`ok` / `degraded` / `down`, leases of the loops). |
28
+ | `scalio_platform.error_report` | Sends unexpected errors to Scalio Monitor, only when `ERROR_REPORT_URL` and `ERROR_REPORT_TOKEN` are set. |
29
+ | `scalio_platform.app_links` | Links and features of the product switcher (`SCALIO_APP_LINKS`, `SCALIO_STAGE`, `SAAS_MODE`). |
30
+
31
+ ## Use in an app
32
+
33
+ ```
34
+ scalio-platform==0.1.0 # backend/requirements.txt, exact version
35
+ ```
36
+
37
+ ```python
38
+ from scalio_platform import error_report, health, job_lease
39
+ from scalio_platform.migrations import BASELINE, run_migrations
40
+
41
+ MIGRATIONS = BASELINE # append (2, migrate_002_...) and so on, never edit past steps
42
+
43
+ async def startup():
44
+ await run_migrations(db, MIGRATIONS) # before the background loops
45
+ for name, loop in LOOPS.items():
46
+ asyncio.create_task(job_lease.run_exclusive(db, name, loop))
47
+
48
+ error_report.install(app, "citadel", build_info)
49
+ code, body = await health.health_report(db, build_info(), lease_jobs=sorted(LOOPS))
50
+ ```
51
+
52
+ A migration step must be safe to run twice: two instances can start at the same moment.
53
+
54
+ ## Develop and release
55
+
56
+ - `pip install -e ".[dev]"`, then `pytest -q` and `ruff check .` (both run in CI).
57
+ - Release: bump `pyproject.toml` and `__version__`, add the CHANGELOG entry, merge, then «Freigeben» in the Scalio
58
+ Cockpit (workflow `publish.yml`: PyPI with trusted publishing, tag and GitHub release). Tags without `v`.
59
+ - A change of behaviour changes every app on its next upgrade: say so in the PR and the CHANGELOG.
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/scalio_platform/__init__.py
4
+ src/scalio_platform/app_links.py
5
+ src/scalio_platform/error_report.py
6
+ src/scalio_platform/health.py
7
+ src/scalio_platform/job_lease.py
8
+ src/scalio_platform/migrations.py
9
+ src/scalio_platform.egg-info/PKG-INFO
10
+ src/scalio_platform.egg-info/SOURCES.txt
11
+ src/scalio_platform.egg-info/dependency_links.txt
12
+ src/scalio_platform.egg-info/requires.txt
13
+ src/scalio_platform.egg-info/top_level.txt
14
+ tests/test_app_links.py
15
+ tests/test_error_report.py
16
+ tests/test_health.py
17
+ tests/test_job_lease.py
18
+ tests/test_migrations.py
19
+ tests/test_texts.py
@@ -0,0 +1,9 @@
1
+ httpx>=0.27
2
+ pymongo>=4.6
3
+
4
+ [dev]
5
+ pytest>=8
6
+ fastapi>=0.110
7
+ httpx>=0.27
8
+ mongomock-motor>=0.0.29
9
+ ruff>=0.6
@@ -0,0 +1 @@
1
+ scalio_platform
@@ -0,0 +1,83 @@
1
+ """Product switcher links (SCALIO_APP_LINKS)."""
2
+ from scalio_platform.app_links import app_links, parse_app_links
3
+
4
+
5
+ def test_known_apps_with_https_links():
6
+ raw = "nexus=https://dev.scalionexus.com/, rosetta = https://dev.scaliorosetta.com ,citadel=http://localhost:3001"
7
+ assert parse_app_links(raw) == {
8
+ "nexus": "https://dev.scalionexus.com",
9
+ "rosetta": "https://dev.scaliorosetta.com",
10
+ "citadel": "http://localhost:3001",
11
+ }
12
+
13
+
14
+ def test_unknown_apps_and_bad_urls_are_ignored():
15
+ raw = "compass=https://portal.scaliocloud.com,foundry=javascript:alert(1),nexus=,rosetta,citadel=https://u:p@x.com,pulse=ftp://x"
16
+ assert parse_app_links(raw) == {}
17
+
18
+
19
+ def test_empty_or_missing_setting(monkeypatch):
20
+ assert parse_app_links("") == {}
21
+ assert parse_app_links(None) == {}
22
+ monkeypatch.delenv("SCALIO_APP_LINKS", raising=False)
23
+ monkeypatch.setenv("SCALIO_STAGE", "dev")
24
+ assert app_links()["links"] == {}
25
+ monkeypatch.setenv("SCALIO_APP_LINKS", "foundry=https://dev.scaliofoundry.com")
26
+ assert app_links()["links"] == {"foundry": "https://dev.scaliofoundry.com"}
27
+
28
+
29
+ def _mode(monkeypatch, stage=None, saas=None, links="nexus=https://dev.scalionexus.com"):
30
+ for key, value in (("SCALIO_STAGE", stage), ("SAAS_MODE", saas), ("SCALIO_APP_LINKS", links)):
31
+ if value is None:
32
+ monkeypatch.delenv(key, raising=False)
33
+ else:
34
+ monkeypatch.setenv(key, value)
35
+ return app_links()
36
+
37
+
38
+ def test_self_hosted_customer_shows_neither_switcher_nor_support(monkeypatch):
39
+ # Nothing set: a customer's own installation. Links are not even sent.
40
+ assert _mode(monkeypatch) == {"links": {}, "stage": "prod", "saas": False,
41
+ "features": {"switcher": False, "support": False}}
42
+
43
+
44
+ def test_dev_and_test_show_switcher_and_support(monkeypatch):
45
+ for stage in ("dev", " TEST "):
46
+ answer = _mode(monkeypatch, stage=stage)
47
+ assert answer["stage"] == stage.strip().lower()
48
+ assert answer["features"] == {"switcher": True, "support": True}
49
+ assert answer["links"] == {"nexus": "https://dev.scalionexus.com"}
50
+
51
+
52
+ def test_demo_shows_switcher_without_support(monkeypatch):
53
+ answer = _mode(monkeypatch, stage="demo")
54
+ assert answer["stage"] == "demo"
55
+ assert answer["features"] == {"switcher": True, "support": False}
56
+
57
+
58
+ def test_saas_shows_both_in_production(monkeypatch):
59
+ answer = _mode(monkeypatch, saas="true")
60
+ assert answer["stage"] == "prod" and answer["saas"] is True
61
+ assert answer["features"] == {"switcher": True, "support": True}
62
+
63
+
64
+ def test_unknown_stage_counts_as_production(monkeypatch):
65
+ answer = _mode(monkeypatch, stage="staging")
66
+ assert answer["stage"] == "prod"
67
+ assert answer["features"] == {"switcher": False, "support": False}
68
+
69
+
70
+ def test_self_hosted_support_follows_the_callers_answer(monkeypatch):
71
+ """ADR 0022, T11: a self-hosted customer shows support while maintenance runs; support.py decides."""
72
+ from scalio_platform.app_links import features
73
+ _mode(monkeypatch)
74
+ assert features()["support"] is False
75
+ assert features(self_hosted_support=True)["support"] is True
76
+ assert app_links(support=True)["features"]["support"] is True
77
+ _mode(monkeypatch, stage="demo")
78
+ assert features(self_hosted_support=True)["support"] is False
79
+
80
+
81
+ def test_beacon_link_is_kept():
82
+ """Scalio Beacon is in the switcher (scalio-ui 0.15.0), so its link must not be dropped."""
83
+ assert parse_app_links("beacon=https://dev.scaliobeacon.com") == {"beacon": "https://dev.scaliobeacon.com"}
@@ -0,0 +1,161 @@
1
+ """error_report.py: unexpected errors go to Scalio Monitor, only when configured, never with request data.
2
+ Module scalio_platform.error_report (formerly a copy in every Scalio Python app)."""
3
+ import asyncio
4
+ import json
5
+
6
+ import httpx
7
+ from fastapi import FastAPI
8
+ from fastapi.testclient import TestClient
9
+
10
+ from scalio_platform import error_report
11
+
12
+
13
+ class Monitor:
14
+ def __init__(self, status=200):
15
+ self.sent, self.status = [], status
16
+
17
+ def handler(self, request: httpx.Request) -> httpx.Response:
18
+ self.sent.append((str(request.url), request.headers.get("authorization"), json.loads(request.content)))
19
+ return httpx.Response(self.status, json={"ok": True})
20
+
21
+
22
+ TOKEN = "tok-123" # fake
23
+
24
+
25
+ def make(monitor, url="https://mon.test", token=None, clock=None):
26
+ token = TOKEN if token is None else token
27
+ app = FastAPI()
28
+
29
+ @app.get("/api/users/{user_id}")
30
+ async def boom(user_id: str):
31
+ raise KeyError(f"user {user_id}")
32
+
33
+ @app.get("/ok")
34
+ async def ok():
35
+ return {"ok": True}
36
+
37
+ kw = {"clock": clock} if clock else {}
38
+ rep = error_report.install(app, "nexus", lambda: {"version": "1.2.5", "commit": "abc"}, url=url, token=token,
39
+ transport=httpx.MockTransport(monitor.handler), **kw)
40
+ client = TestClient(app, raise_server_exceptions=False)
41
+ client.__enter__() # keeps one event loop, so the background report can be awaited
42
+ return client, rep
43
+
44
+
45
+ def call(client, rep, path, **kw):
46
+ r = client.get(path, **kw)
47
+ client.portal.call(rep.drain)
48
+ return r
49
+
50
+
51
+ def teardown_function():
52
+ error_report._reporter = None
53
+
54
+
55
+ def test_unhandled_error_is_reported_without_request_data():
56
+ mon = Monitor()
57
+ client, rep = make(mon)
58
+ r = call(client, rep, "/api/users/u-42?token=secret-q", headers={"Cookie": "sid=cookie-secret",
59
+ "Authorization": "Bearer user-secret"})
60
+ assert r.status_code == 500
61
+ assert len(mon.sent) == 1
62
+ url, auth, body = mon.sent[0]
63
+ assert url == "https://mon.test/ingest/error" and auth == "Bearer tok-123"
64
+ assert body["app"] == "nexus" and body["kind"] == "backend" and body["type"] == "KeyError"
65
+ assert body["path"] == "/api/users/{user_id}" and body["method"] == "GET" and body["status"] == 500
66
+ assert body["version"] == "1.2.5" and body["commit"] == "abc" and "boom" in body["stack"]
67
+ raw = json.dumps(body)
68
+ for secret in ("secret-q", "cookie-secret", "user-secret"):
69
+ assert secret not in raw
70
+
71
+
72
+ def test_off_without_url_or_token_and_nothing_for_good_requests():
73
+ mon = Monitor()
74
+ client, rep = make(mon, token="")
75
+ assert not rep.enabled
76
+ call(client, rep, "/api/users/1")
77
+ client2, rep2 = make(mon)
78
+ assert call(client2, rep2, "/ok").status_code == 200
79
+ assert mon.sent == []
80
+
81
+
82
+ def test_same_error_once_per_minute():
83
+ now = [1000.0]
84
+ mon = Monitor()
85
+ client, rep = make(mon, clock=lambda: now[0])
86
+ call(client, rep, "/api/users/1")
87
+ call(client, rep, "/api/users/2")
88
+ assert len(mon.sent) == 1
89
+ now[0] += 61
90
+ call(client, rep, "/api/users/3")
91
+ assert len(mon.sent) == 2
92
+
93
+
94
+ def test_monitor_down_does_not_change_the_answer(caplog):
95
+ def down(request):
96
+ raise httpx.ConnectError("no route", request=request)
97
+ app = FastAPI()
98
+
99
+ @app.get("/x")
100
+ async def x():
101
+ raise ValueError("x")
102
+ rep = error_report.install(app, "nexus", url="https://mon.test", token="tok-123",
103
+ transport=httpx.MockTransport(down))
104
+ with TestClient(app, raise_server_exceptions=False) as client:
105
+ assert client.get("/x").status_code == 500
106
+ client.portal.call(rep.drain)
107
+ assert "tok-123" not in caplog.text
108
+
109
+
110
+ def test_route_template_without_a_route():
111
+ path = "/api/items/123/sub/0123456789abcdef01234567"
112
+ assert error_report.route_template({"path": path}) == "/api/items/{id}/sub/{id}"
113
+
114
+
115
+ def test_background_job_report():
116
+ mon = Monitor()
117
+ rep = error_report.Reporter("nexus", url="https://mon.test", token="t", transport=httpx.MockTransport(mon.handler))
118
+ error_report._reporter = rep
119
+
120
+ async def run():
121
+ try:
122
+ raise RuntimeError("loop broke")
123
+ except RuntimeError as exc:
124
+ await error_report.report_exception(exc, path="lifecycle_task")
125
+ await rep.drain()
126
+ asyncio.run(run())
127
+ assert mon.sent[0][2]["kind"] == "job" and mon.sent[0][2]["path"] == "lifecycle_task"
128
+
129
+
130
+ class GroupingMonitor(Monitor):
131
+ """Answers like Scalio Monitor: the fingerprint of the group the report landed in."""
132
+
133
+ def handler(self, request: httpx.Request) -> httpx.Response:
134
+ super().handler(request)
135
+ body = json.loads(request.content)
136
+ return httpx.Response(200, json={"ok": True, "group": "0123456789abcdef" if body["type"] == "KeyError"
137
+ else "fedcba9876543210", "new": True})
138
+
139
+
140
+ def test_reported_groups_are_kept_for_support_tickets():
141
+ """A support ticket lists the last error groups of the instance with a link to the monitor (ADR 0022, T9)."""
142
+ mon = GroupingMonitor()
143
+ t = [0.0]
144
+ client, rep = make(mon, clock=lambda: t[0])
145
+ call(client, rep, "/api/users/1")
146
+ t[0] += 120 # past the repeat window
147
+ call(client, rep, "/api/users/2")
148
+ groups = error_report.recent_groups()
149
+ assert len(groups) == 1
150
+ g = groups[0]
151
+ assert g["fingerprint"] == "0123456789abcdef" and g["type"] == "KeyError" and g["count"] == 2
152
+ assert g["route"] == "/api/users/{user_id}" and g["monitor"] == "https://mon.test" and g["time"]
153
+ assert asyncio.run(error_report.errors_for_support(None)) == groups
154
+
155
+
156
+ def test_no_groups_without_reporter_or_with_a_bad_answer():
157
+ assert error_report.recent_groups() == []
158
+ mon = Monitor() # answers without "group"
159
+ client, rep = make(mon)
160
+ call(client, rep, "/api/users/1")
161
+ assert error_report.recent_groups() == []
@@ -0,0 +1,71 @@
1
+ """GET /api/health (health.py): the answer the monitoring dashboard reads. Offline (mongomock)."""
2
+ import asyncio
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from mongomock_motor import AsyncMongoMockClient
6
+
7
+ from scalio_platform import health as H
8
+
9
+ BUILD = {"version": "1.2.5", "commit": "e93339f", "build_date": "2026-10-27", "service": "backend"}
10
+ NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc)
11
+
12
+
13
+ def run(coro):
14
+ return asyncio.run(coro)
15
+
16
+
17
+ def test_ok_with_running_jobs():
18
+ async def go():
19
+ db = AsyncMongoMockClient()["health_test"]
20
+ await db.job_leases.insert_one({"_id": "workflow_task", "until": NOW + timedelta(seconds=40),
21
+ "renewedAt": NOW - timedelta(seconds=20), "owner": "a"})
22
+ return await H.health_report(db, BUILD, lease_jobs=["workflow_task"], now=NOW)
23
+ code, body = run(go())
24
+ assert code == 200
25
+ assert body == {"status": "ok", "version": "1.2.5", "commit": "e93339f", "db": "ok",
26
+ "jobs": {"workflow_task": {"ok": True, "last_seen": "2026-10-27T11:59:40+00:00"}}}
27
+
28
+
29
+ def test_degraded_when_a_lease_expired_or_is_missing():
30
+ async def go():
31
+ db = AsyncMongoMockClient()["health_test"]
32
+ await db.job_leases.insert_one({"_id": "pam_worker_task", "until": NOW - timedelta(minutes=5),
33
+ "renewedAt": NOW - timedelta(minutes=6)})
34
+ return await H.health_report(db, BUILD, lease_jobs=["pam_worker_task", "hr_task"], now=NOW)
35
+ code, body = run(go())
36
+ assert code == 200 and body["status"] == "degraded"
37
+ assert body["jobs"]["pam_worker_task"]["ok"] is False
38
+ assert body["jobs"]["hr_task"] == {"ok": False, "last_seen": None}
39
+
40
+
41
+ def test_old_lease_names_do_not_count():
42
+ async def go():
43
+ db = AsyncMongoMockClient()["health_test"]
44
+ await db.job_leases.insert_one({"_id": "removed_loop", "until": NOW - timedelta(days=3)})
45
+ return await H.health_report(db, BUILD, lease_jobs=[], now=NOW)
46
+ code, body = run(go())
47
+ assert code == 200 and body["status"] == "ok" and body["jobs"] == {}
48
+
49
+
50
+ class DeadDb:
51
+ async def command(self, _name):
52
+ await asyncio.sleep(10)
53
+
54
+
55
+ def test_down_with_503_when_the_database_does_not_answer():
56
+ code, body = run(H.health_report(DeadDb(), BUILD, lease_jobs=["x"], timeout=0.05, now=NOW))
57
+ assert code == 503
58
+ assert body == {"status": "down", "version": "1.2.5", "commit": "e93339f", "db": "error", "jobs": {}}
59
+
60
+
61
+ def test_no_secrets_or_counts_in_the_answer():
62
+ async def go():
63
+ db = AsyncMongoMockClient()["health_test"]
64
+ return await H.health_report(db, BUILD, now=NOW)
65
+ _code, body = run(go())
66
+ assert set(body) == {"status", "version", "commit", "db", "jobs"}
67
+
68
+
69
+ def test_app_without_database():
70
+ code, body = run(H.health_report(None, BUILD, now=NOW))
71
+ assert code == 200 and body["status"] == "ok" and body["db"] == "none"
@@ -0,0 +1,129 @@
1
+ """Background loops run on one backend instance at a time (job_lease.py, architecture review C1).
2
+ Offline (mongomock).
3
+ """
4
+ import asyncio
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from mongomock_motor import AsyncMongoMockClient
8
+
9
+ from scalio_platform import job_lease as L
10
+
11
+
12
+ def fresh_db():
13
+ return AsyncMongoMockClient()["lease_test"]
14
+
15
+
16
+ def test_only_one_owner_holds_a_lease():
17
+ async def run():
18
+ db = fresh_db()
19
+ assert await L.acquire(db, "workflow_task", "a")
20
+ assert not await L.acquire(db, "workflow_task", "b")
21
+ assert await L.acquire(db, "workflow_task", "a") # renewal by the holder
22
+ assert await L.acquire(db, "other_task", "b") # leases are per loop
23
+ asyncio.run(run())
24
+
25
+
26
+ def test_expired_lease_is_taken_over_and_release_frees_it():
27
+ async def run():
28
+ db = fresh_db()
29
+ assert await L.acquire(db, "pam_worker_task", "a")
30
+ await db.job_leases.update_one({"_id": "pam_worker_task"},
31
+ {"$set": {"until": datetime.now(timezone.utc) - timedelta(seconds=1)}})
32
+ assert await L.acquire(db, "pam_worker_task", "b")
33
+ await L.release(db, "pam_worker_task", "a") # not the holder: no effect
34
+ assert not await L.acquire(db, "pam_worker_task", "a")
35
+ await L.release(db, "pam_worker_task", "b")
36
+ assert await L.acquire(db, "pam_worker_task", "a")
37
+ asyncio.run(run())
38
+
39
+
40
+ def test_two_instances_never_run_the_same_loop_together():
41
+ """Instance A and B start the same loop side by side (rolling update): only one runs it at any time;
42
+ when A stops, B takes over."""
43
+ async def run():
44
+ db = fresh_db()
45
+ running, max_running, ran = set(), [0], []
46
+
47
+ def make(owner):
48
+ async def loop():
49
+ running.add(owner)
50
+ ran.append(owner)
51
+ max_running[0] = max(max_running[0], len(running))
52
+ try:
53
+ while True:
54
+ await asyncio.sleep(0.01)
55
+ finally:
56
+ running.discard(owner)
57
+ return loop
58
+
59
+ kw = {"ttl": 0.3, "renew": 0.05, "wait": 0.05}
60
+ a = asyncio.create_task(L.run_exclusive(db, "lifecycle_task", make("a"), owner="a", **kw))
61
+ await asyncio.sleep(0.05)
62
+ b = asyncio.create_task(L.run_exclusive(db, "lifecycle_task", make("b"), owner="b", **kw))
63
+ await asyncio.sleep(0.4)
64
+ assert running == {"a"} and max_running[0] == 1
65
+ a.cancel() # graceful stop of A releases the lease
66
+ await asyncio.gather(a, return_exceptions=True)
67
+ await asyncio.sleep(0.2)
68
+ assert running == {"b"} and ran == ["a", "b"] and max_running[0] == 1
69
+ b.cancel()
70
+ await asyncio.gather(b, return_exceptions=True)
71
+ assert await db.job_leases.count_documents({}) == 0
72
+ asyncio.run(run())
73
+
74
+
75
+ def test_loop_stops_when_the_lease_is_taken_over():
76
+ async def run():
77
+ db = fresh_db()
78
+ stopped = asyncio.Event()
79
+
80
+ async def loop():
81
+ try:
82
+ while True:
83
+ await asyncio.sleep(0.01)
84
+ finally:
85
+ stopped.set()
86
+
87
+ t = asyncio.create_task(L.run_exclusive(db, "hr_runtime_task", loop, owner="a", ttl=5, renew=0.05, wait=10))
88
+ await asyncio.sleep(0.05)
89
+ until = datetime.now(timezone.utc) + timedelta(seconds=60)
90
+ await db.job_leases.update_one({"_id": "hr_runtime_task"}, {"$set": {"owner": "b", "until": until}})
91
+ await asyncio.wait_for(stopped.wait(), timeout=1)
92
+ t.cancel()
93
+ await asyncio.gather(t, return_exceptions=True)
94
+ assert (await db.job_leases.find_one({"_id": "hr_runtime_task"}))["owner"] == "b"
95
+ asyncio.run(run())
96
+
97
+
98
+ def test_short_database_outage_keeps_the_loop_running():
99
+ async def run():
100
+ db = fresh_db()
101
+ ticks = []
102
+
103
+ async def loop():
104
+ while True:
105
+ ticks.append(1)
106
+ await asyncio.sleep(0.01)
107
+
108
+ real = L.acquire
109
+ calls = {"n": 0}
110
+
111
+ async def flaky(*a, **k):
112
+ calls["n"] += 1
113
+ if calls["n"] in (2, 3):
114
+ raise RuntimeError("mongo unreachable")
115
+ return await real(*a, **k)
116
+
117
+ L.acquire = flaky
118
+ try:
119
+ t = asyncio.create_task(L.run_exclusive(db, "exo_task", loop, owner="a", ttl=1, renew=0.05, wait=0.05))
120
+ await asyncio.sleep(0.3)
121
+ assert not t.done()
122
+ before = len(ticks)
123
+ await asyncio.sleep(0.05)
124
+ assert len(ticks) > before # still running after two failed renewals
125
+ t.cancel()
126
+ await asyncio.gather(t, return_exceptions=True)
127
+ finally:
128
+ L.acquire = real
129
+ asyncio.run(run())
@@ -0,0 +1,91 @@
1
+ """Schema migrations (scalio_platform.migrations): each step once, version in db.meta, safe with two instances."""
2
+ import asyncio
3
+
4
+ import pytest
5
+ from mongomock_motor import AsyncMongoMockClient
6
+
7
+ from scalio_platform import migrations as M
8
+
9
+
10
+ def fresh_db():
11
+ return AsyncMongoMockClient()["migrations_test"]
12
+
13
+
14
+ def test_new_database_runs_every_step_once_and_stores_the_version():
15
+ async def run():
16
+ db, calls = fresh_db(), []
17
+
18
+ async def m2(db):
19
+ calls.append(2)
20
+ await db.items.update_many({}, {"$rename": {"old": "new"}})
21
+
22
+ async def m3(db):
23
+ calls.append(3)
24
+
25
+ await db.items.insert_one({"old": 1})
26
+ steps = M.BASELINE + [(2, m2), (3, m3)]
27
+ assert await M.schema_version(db) == 0
28
+ assert await M.run_migrations(db, steps) == 3
29
+ assert calls == [2, 3]
30
+ assert await db.items.find_one({}, {"_id": 0}) == {"new": 1}
31
+ assert await M.run_migrations(db, steps) == 3 # second start: nothing runs again
32
+ assert calls == [2, 3]
33
+ doc = await db.meta.find_one({"id": "schema_version"})
34
+ assert doc["version"] == 3 and doc["appliedAt"]
35
+ asyncio.run(run())
36
+
37
+
38
+ def test_nexus_history_is_kept():
39
+ """Nexus already stored version 1 in the same document: only newer steps run."""
40
+ async def run():
41
+ db, calls = fresh_db(), []
42
+ await db.meta.insert_one({"id": "schema_version", "version": 1, "appliedAt": "2026-10-27T00:00:00+00:00"})
43
+
44
+ async def m2(db):
45
+ calls.append(2)
46
+
47
+ assert await M.run_migrations(db, M.BASELINE + [(2, m2)]) == 2
48
+ assert calls == [2]
49
+ asyncio.run(run())
50
+
51
+
52
+ def test_failing_step_keeps_the_last_good_version():
53
+ async def run():
54
+ db = fresh_db()
55
+
56
+ async def broken(db):
57
+ raise RuntimeError("boom")
58
+
59
+ with pytest.raises(RuntimeError):
60
+ await M.run_migrations(db, M.BASELINE + [(2, broken)])
61
+ assert await M.schema_version(db) == 1
62
+ asyncio.run(run())
63
+
64
+
65
+ def test_newer_database_is_left_alone():
66
+ async def run():
67
+ db = fresh_db()
68
+ await db.meta.insert_one({"id": "schema_version", "version": 7})
69
+ assert await M.run_migrations(db, M.BASELINE) == 7
70
+ assert await M.schema_version(db) == 7
71
+ asyncio.run(run())
72
+
73
+
74
+ def test_version_never_goes_back_when_two_instances_race():
75
+ async def run():
76
+ db = fresh_db()
77
+
78
+ async def m2(db):
79
+ await asyncio.sleep(0.01)
80
+
81
+ steps = M.BASELINE + [(2, m2)]
82
+ await asyncio.gather(M.run_migrations(db, steps), M.run_migrations(db, steps))
83
+ assert await M.schema_version(db) == 2
84
+ assert await db.meta.count_documents({"id": "schema_version"}) == 1
85
+ asyncio.run(run())
86
+
87
+
88
+ @pytest.mark.parametrize("steps", [[(2, None), (1, None)], [(1, None), (1, None)], [(0, None)]])
89
+ def test_bad_step_lists_are_refused(steps):
90
+ with pytest.raises(ValueError):
91
+ asyncio.run(M.run_migrations(fresh_db(), steps))
@@ -0,0 +1,10 @@
1
+ """German text is written with "ss", never the sharp s (project rule)."""
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parent.parent
5
+
6
+
7
+ def test_no_sharp_s():
8
+ sharp = "ß"
9
+ for p in list(ROOT.glob("src/**/*.py")) + list(ROOT.glob("*.md")):
10
+ assert sharp not in p.read_text(encoding="utf-8"), p