refactor: converge the patterns multiple authors left divergent
The codebase was written by several agents and had the same concept done more
than one way. This makes it read as one voice, with no behaviour change.
Dedup, each to a single canonical form:
- INSTANCE_COLUMNS: the 13-column instances SELECT list existed as _COLUMNS in
instances.py and reconcile.py (byte-identical) and inlined a third time in
the worker. One exported constant now.
- Settings.runtime_dsn: the three entrypoints each chose between str(pg_dsn)
and pg_dsn.unicode_string(). One property.
- yaml_tempfile: helm._ValuesFile and k8s._ManifestFile were the same
write-yaml-to-a-temp-dir context manager. One helper in adapters/tempyaml.py.
- services/_runtime.py: sleep_or_stop and install_stop_signals were copied
between the worker and reconciler loops. One module, so shutdown behaviour
cannot drift between them.
- k8s.ensure_namespace used MANAGED_BY_LABEL/VALUE from helm.py instead of a
hardcoded literal, so the managed-by label has one definition.
- SvcforgeError is now the root of every svcforge exception (CatalogError,
IllegalTransition, BadWindow, HandlerError), keeping each stdlib base in the
MRO, so `except SvcforgeError` means what errors.py says it does.
- ERROR_MAX_CHARS replaces the repeated `[-2000:]` truncation feeding the same
error columns.
- the reconciler reads settings.metrics_port like the worker, dropping its
duplicate DEFAULT_METRICS_PORT and redundant --metrics-port option; the
SVCFORGE_METRICS_PORT env override still applies through pydantic.
Two smaller correctness/consistency fixes:
- RateLimitResult.retry_after_s computed its delta against datetime.now(UTC)
while the limiter runs on an injectable clock, so it was meaningless under a
FakeClock and drifted by request latency in production. It now carries a
checked_at from the same clock as reset_at.
- handle_provision's notifier.send is wrapped like the reconciler's: a flaky
webhook after the READY CAS would fail the task, and the retry would hit the
READY early-return and drop the notification, turning a good provision into a
failed one.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""Shared asyncio scaffolding for the long-lived services.
|
||||
|
||||
The worker and the reconciler are both a loop that runs until SIGTERM. They wake immediately
|
||||
on shutdown rather than sleeping through it, and they install the same loop-safe signal
|
||||
handlers. Both lived in each service before; keeping one copy means the shutdown behaviour
|
||||
cannot drift between them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
|
||||
|
||||
async def sleep_or_stop(stop: asyncio.Event, seconds: float) -> None:
|
||||
"""Sleep for `seconds`, but return the instant `stop` is set.
|
||||
|
||||
`await asyncio.sleep(seconds)` would make every SIGTERM cost up to `seconds` of
|
||||
Kubernetes waiting on terminationGracePeriod for nothing.
|
||||
"""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||
|
||||
|
||||
def install_stop_signals(stop: asyncio.Event) -> None:
|
||||
"""Set `stop` on SIGTERM and SIGINT, loop-safely.
|
||||
|
||||
add_signal_handler, not signal.signal. signal.signal runs the handler at an arbitrary
|
||||
bytecode boundary on whatever thread the C-level handler lands on, and the loop does not
|
||||
notice until its next timer fires — up to a full sleep interval away. add_signal_handler
|
||||
schedules the callback as an ordinary loop callback, so the sleep_or_stop above returns
|
||||
at once.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
RateLimiter(redis, limit=settings.rate_limit_per_minute, window_s=60) if redis is not None else None
|
||||
)
|
||||
|
||||
pool = make_pool(str(settings.pg_dsn), settings.pool_min_size, settings.pool_max_size)
|
||||
pool = make_pool(settings.runtime_dsn, settings.pool_min_size, settings.pool_max_size)
|
||||
# wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request,
|
||||
# as a PoolTimeout, in front of a user.
|
||||
await pool.open(wait=True)
|
||||
|
||||
+11
-29
@@ -23,19 +23,18 @@ Three rules hold the design together:
|
||||
binary that cannot reach the API server must not stop TTLs from expiring.
|
||||
* **Enqueue, never act.** The reconciler diagnoses; workers treat. It writes task rows and
|
||||
instance states, and never calls `helm install`. The one exception is reading — the drift
|
||||
check runs `helm list`, because seeing reality is the job.
|
||||
check lists the live releases, because seeing reality is the job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import typer
|
||||
|
||||
from services._runtime import install_stop_signals, sleep_or_stop
|
||||
from svcforge_core.adapters.clock import Clock, SystemClock
|
||||
from svcforge_core.adapters.helm import HelmProvisioner, Provisioner
|
||||
from svcforge_core.adapters.notify import LogNotifier, Notifier
|
||||
@@ -59,9 +58,6 @@ from svcforge_core.settings import Settings, load_settings
|
||||
|
||||
log = get_logger("svcforge.reconciler")
|
||||
|
||||
# The chart's PodMonitor scrapes the port named `metrics` on 9000. Keep them in step.
|
||||
DEFAULT_METRICS_PORT = 9000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcilerDeps:
|
||||
@@ -92,7 +88,7 @@ class ReconcilerDeps:
|
||||
|
||||
|
||||
async def check_drift(deps: ReconcilerDeps) -> None:
|
||||
"""`helm list -A -o json` versus what the database believes.
|
||||
"""The live helm releases versus what the database believes.
|
||||
|
||||
This is the only check that looks outside Postgres, and the only one that can catch the
|
||||
failure nothing else can: someone ran `helm uninstall` by hand, or a node was drained
|
||||
@@ -312,12 +308,6 @@ async def _run_checks(deps: ReconcilerDeps) -> None:
|
||||
log.exception("gauges.failed")
|
||||
|
||||
|
||||
async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None:
|
||||
"""Sleep, but wake immediately on SIGTERM. A 60s nap must not cost 60s of shutdown."""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||
|
||||
|
||||
async def run_reconciler(deps: ReconcilerDeps, stop: asyncio.Event) -> None:
|
||||
"""Tick, sleep, repeat, until told to stop.
|
||||
|
||||
@@ -328,7 +318,7 @@ async def run_reconciler(deps: ReconcilerDeps, stop: asyncio.Event) -> None:
|
||||
"""
|
||||
while not stop.is_set():
|
||||
await tick(deps)
|
||||
await _sleep_or_stop(stop, deps.settings.reconcile_interval_s)
|
||||
await sleep_or_stop(stop, deps.settings.reconcile_interval_s)
|
||||
|
||||
|
||||
def build_deps(
|
||||
@@ -353,11 +343,11 @@ def build_deps(
|
||||
)
|
||||
|
||||
|
||||
async def _amain(once: bool, metrics_port: int, own_team: str, max_in_flight: int) -> None:
|
||||
async def _amain(once: bool, own_team: str, max_in_flight: int) -> None:
|
||||
settings = load_settings()
|
||||
setup("svcforge-reconciler", settings)
|
||||
|
||||
pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size)
|
||||
pool = make_pool(settings.runtime_dsn, settings.pool_min_size, settings.pool_max_size)
|
||||
await pool.open(wait=True)
|
||||
deps = build_deps(pool, settings, own_team, max_in_flight)
|
||||
|
||||
@@ -368,17 +358,12 @@ async def _amain(once: bool, metrics_port: int, own_team: str, max_in_flight: in
|
||||
await tick(deps)
|
||||
return
|
||||
|
||||
start_metrics_server(metrics_port)
|
||||
# settings.metrics_port, like the worker. SVCFORGE_METRICS_PORT still overrides it,
|
||||
# through pydantic rather than a second CLI option, so the port has one definition.
|
||||
start_metrics_server(settings.metrics_port)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
# add_signal_handler, NOT signal.signal. signal.signal fires the handler at an
|
||||
# arbitrary bytecode boundary on the main thread and the loop does not notice
|
||||
# until its next timer — which here is up to a full 60s tick away. This one is
|
||||
# scheduled as an ordinary loop callback, so the `stop.wait()` above returns
|
||||
# immediately.
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
install_stop_signals(stop)
|
||||
|
||||
await run_reconciler(deps, stop)
|
||||
finally:
|
||||
@@ -391,9 +376,6 @@ app = typer.Typer(add_completion=False, help="svcforge reconciler: the control l
|
||||
@app.command()
|
||||
def main(
|
||||
once: bool = typer.Option(False, "--once", help="Run one tick and exit."),
|
||||
metrics_port: int = typer.Option(
|
||||
DEFAULT_METRICS_PORT, envvar="SVCFORGE_METRICS_PORT", help="Port for /metrics."
|
||||
),
|
||||
own_team: str = typer.Option(
|
||||
"platform", envvar="SVCFORGE_OWN_TEAM", help="Team whose instances upgrade first."
|
||||
),
|
||||
@@ -403,7 +385,7 @@ def main(
|
||||
) -> None:
|
||||
"""Run the reconciler."""
|
||||
# One asyncio.run, at the top, never nested. Everything below it is already async.
|
||||
asyncio.run(_amain(once, metrics_port, own_team, max_in_flight))
|
||||
asyncio.run(_amain(once, own_team, max_in_flight))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -18,18 +18,21 @@ from typing import Any
|
||||
from services.worker.deps import WorkerDeps
|
||||
from svcforge_core.domain.models import CatalogEntry, Instance, Task, TaskKind
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.errors import SvcforgeError
|
||||
from svcforge_core.obs import get_logger
|
||||
from svcforge_core.repo.instances import INSTANCE_COLUMNS
|
||||
|
||||
log = get_logger("svcforge.worker")
|
||||
|
||||
|
||||
class HandlerError(RuntimeError):
|
||||
class HandlerError(SvcforgeError, RuntimeError):
|
||||
"""A task failed in a way worth retrying. The message lands in tasks.last_error."""
|
||||
|
||||
|
||||
async def _load_instance(task: Task, deps: WorkerDeps) -> Instance:
|
||||
async with deps.pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""select id, team, service_type, size, state, namespace, release_name,
|
||||
chart_version, endpoint, error, expires_at, created_at, updated_at
|
||||
from instances where id = %s""",
|
||||
f"select {INSTANCE_COLUMNS} from instances where id = %s", # noqa: S608 - module constant
|
||||
(task.instance_id,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
@@ -75,11 +78,19 @@ async def handle_provision(task: Task, deps: WorkerDeps) -> None:
|
||||
inst.id, InstanceState.PROVISIONING, InstanceState.READY, endpoint=endpoint
|
||||
)
|
||||
if ok:
|
||||
await deps.notifier.send(
|
||||
"instance.ready",
|
||||
f"instance {inst.id} is ready at {endpoint}",
|
||||
{"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type},
|
||||
)
|
||||
try:
|
||||
await deps.notifier.send(
|
||||
"instance.ready",
|
||||
f"instance {inst.id} is ready at {endpoint}",
|
||||
{"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type},
|
||||
)
|
||||
except Exception:
|
||||
# The provision succeeded and the row is already READY; the notification is a
|
||||
# courtesy. Letting a webhook timeout propagate would fail the task, and the
|
||||
# retry would hit the READY early-return and drop the notification anyway — so a
|
||||
# flaky notifier would turn every provision into a "failed" task. Same guard the
|
||||
# reconciler puts around its own notify.
|
||||
log.exception("notify.failed", instance_id=str(inst.id))
|
||||
|
||||
|
||||
async def handle_deprovision(task: Task, deps: WorkerDeps) -> None:
|
||||
|
||||
+5
-22
@@ -10,13 +10,12 @@ for something better.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from services._runtime import install_stop_signals, sleep_or_stop
|
||||
from services.worker.deps import WorkerDeps
|
||||
from services.worker.handlers import HANDLERS
|
||||
from svcforge_core import obs
|
||||
@@ -33,16 +32,6 @@ from svcforge_core.settings import Settings, load_settings
|
||||
log = obs.get_logger("svcforge.worker")
|
||||
|
||||
|
||||
async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None:
|
||||
"""Sleep, but wake immediately on shutdown.
|
||||
|
||||
`await asyncio.sleep(5)` would make every SIGTERM cost up to five seconds of
|
||||
Kubernetes waiting on terminationGracePeriod for no reason.
|
||||
"""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||
|
||||
|
||||
async def _report(coro: Awaitable[bool], task_id: int, what: str) -> None:
|
||||
"""Run a terminal report, and never let its failure escape.
|
||||
|
||||
@@ -154,12 +143,12 @@ async def run_worker(deps: WorkerDeps, stop: asyncio.Event) -> None:
|
||||
# A DB blip must not kill the worker; back off and try again.
|
||||
log.exception("claim failed")
|
||||
sem.release()
|
||||
await _sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
await sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
sem.release()
|
||||
await _sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
await sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
continue
|
||||
|
||||
tg.create_task(_run_one(deps, task, sem))
|
||||
@@ -176,7 +165,7 @@ async def _amain() -> None:
|
||||
settings.check_production()
|
||||
obs.start_metrics_server(settings.metrics_port)
|
||||
|
||||
pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size)
|
||||
pool = make_pool(settings.runtime_dsn, settings.pool_min_size, settings.pool_max_size)
|
||||
await pool.open(wait=True)
|
||||
|
||||
deps = WorkerDeps(
|
||||
@@ -191,13 +180,7 @@ async def _amain() -> None:
|
||||
)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
# add_signal_handler, NOT signal.signal. signal.signal runs the handler at an
|
||||
# arbitrary bytecode boundary on whatever thread the C-level handler lands on,
|
||||
# and the event loop will not notice until its next timer fires. This one is
|
||||
# loop-safe: the callback runs as a normal loop callback.
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
install_stop_signals(stop)
|
||||
|
||||
try:
|
||||
await run_worker(deps, stop)
|
||||
|
||||
Reference in New Issue
Block a user