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:
Nguyen Minh Phuc
2026-07-21 01:42:25 +00:00
parent d64c3c9f39
commit 66eb6cb0ee
19 changed files with 166 additions and 143 deletions
+37
View File
@@ -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)