KeepAlive im Verbindungspool

Beide Pool-Clients können ihre Leerlaufverbindungen in einem festen Intervall mit dem Serverjob krn.CheckServerConnection anpingen. Der Job ist serverseitig nur ein Zähler, der über die bestehende Session läuft — das billigste verfügbare Lebenszeichen. Er hält NAT-, Firewall- und Idle-Timeouts offen und deckt still weggeräumte Sockets auf, weil die Schreib- oder Leseoperation dann fehlschlägt.

Ohne KeepAlive fällt eine tote Verbindung im Sync-Pool erst beim nächsten fachlichen Aufruf auf: dieser Aufruf schlägt fehl, der Pool wirft die Verbindung weg, und erst der Aufruf danach läuft wieder. Der Async-Pool erfasst jeden Verlust, den der Kernel beobachten kann (Server oder ein Gerät dazwischen schliesst den Socket), dagegen sofort über das connection_lost-Ereignis des Transports — auch ohne KeepAlive. Das KeepAlive ist das verbleibende Sicherheitsnetz: Es fängt die Verluste, die der Kernel nicht sehen kann — weggeräumte Firewall-Regel, abgelaufener NAT-Eintrag — und ist zugleich der einzige Detektionsmechanismus des Sync-Pools.

1. Konfiguration

from ecmind_blue_client.pool import SyncPoolClient

pool = SyncPoolClient(
    servers="server1:4000:1",
    username="<user>",
    password="<pass>",
    keepalive_interval=300,   # Sekunden
)

None (Default)

KeepAlive deaktiviert, es wird kein Hintergrund-Worker gestartet

⇐ 0

ebenfalls deaktiviert

> 0

Sekunden zwischen zwei Durchläufen

AsyncPoolClient nimmt denselben Parameter entgegen und verwendet statt eines Threads einen asyncio.Task.

Das Intervall richtet sich nach dem kürzesten Idle-Timeout auf dem Weg zum Server (Firewall, Loadbalancer, NAT-Gateway) und sollte deutlich darunter liegen. 300 Sekunden sind ein brauchbarer Startwert.

2. Ablauf eines Durchlaufs

  1. Der Worker startet verzögert: erst wenn die erste Verbindung erzeugt wird. Ein Pool, der nie benutzt wird, erzeugt keinen Thread und keinen Task.

  2. Das Intervall wird zwischen dem Ende eines Durchlaufs und dem Beginn des nächsten gemessen. Ein langsamer Durchlauf führt daher weder zu Aufholbursts noch zu überlappenden Durchläufen.

  3. Pro Durchlauf wird die Anzahl der aktuell freien Verbindungen als Momentaufnahme genommen; genau so viele Verbindungen werden geprüft. Eine geprüfte und zurückgelegte Verbindung kann im selben Durchlauf nicht erneut geprüft werden.

  4. Jede entnommene Verbindung wird mit krn.CheckServerConnection (Flags=0) geprüft:

    Erfolg

    Verbindung geht zurück in den Pool, keepalive_count und last_keepalive_at werden hochgezählt

    Fehler (Returncode != 0 oder Netzwerkfehler)

    Verbindung wird geschlossen und aus der Buchführung entfernt

3. Sofort-Erkennung verlorener Verbindungen (Async-Pool)

Der Async-Pool beobachtet das connection_lost-Ereignis des Transports jeder gepoolten Verbindung. Schliesst der Server — oder ein Gerät dazwischen — den Socket, während die Verbindung im Pool liegt, bemerkt der Pool den Verlust im selben Moment, ohne auf den nächsten Ausleih oder Durchlauf zu warten:

  • Die Verbindung wird aus der Buchführung und aus der Leerlauf-Warteschlange entfernt; der nächste Aufruf baut transparent eine frische Verbindung auf.

  • Es erscheint eine INFO-Zeile im Logger ecmind_blue_client.pool._async_pool_client (inklusive einmaligem Hinweis, das KeepAlive zu aktivieren, falls es deaktiviert ist).

Ist die Verbindung gerade ausgeliehen, wenn sie stirbt, wird der Verlust nur auf DEBUG protokolliert: Der aufrufende Code erhält den Verbindungsfehler ohnehin und entsorgt die Verbindung über seinen üblichen Ausnahme-Pfad.

Der Hook deckt vom Gegenpeer veranlasste Schliessungen ab — Server-Idle-Timeouts, Serverneustart, explizites Schliessen. Eine still weggeworfene Verbindung (weggeräumte Firewall-Regel, abgelaufener NAT-Eintrag) sieht er nicht, weil dann gar keine Schliessung den Socket erreicht; dafür ist das KeepAlive da. Der Sync-Pool hat dazu keine Event-Loop-Variante und verlässt sich auf den KeepAlive-Durchlauf.

Die beiden Mechanismen teilen den Fehlerraum: Der Hook reagiert sofort auf jeden Verlust, den der Kernel beobachten kann; das KeepAlive pollt für die Verluste, die der Kernel nicht sehen kann.

4. Eigenschaften

Nur Leerlaufverbindungen

Eine gerade ausgeliehene Verbindung ist aus der Warteschlange heraus und für den Durchlauf unsichtbar. Laufende Jobs werden nie gestört.

Keine Sperre über den RPC-Aufruf

Nur die Warteschlangen-Buchführung läuft unter dem Pool-Lock, der Serverjob selbst nicht. Ein hängender Server blockiert den Pool also nicht für die Dauer des Durchlaufs.

Kein Neuaufbau im Durchlauf

Eine kaputte Verbindung wird nur geschlossen, nicht ersetzt. Der Pool schrumpft, statt kaputte Slots zu blockieren; der nächste Aufruf erzeugt bei Bedarf eine frische Verbindung.

Kein Fehler nach aussen

Ausfälle landen ausschliesslich auf logging.DEBUG im Logger ecmind_blue_client.pool._sync_pool_client bzw. …​_async_pool_client. Es gibt kein Event, keinen Callback und keine Exception im aufrufenden Thread.

Getrennt von der Anwendungsstatistik

KeepAlive-Verkehr zählt in keepalive_count / last_keepalive_at, nicht in call_count / last_call_at.

5. Was das KeepAlive nicht tut

  • Es prüft nicht die konfigurierten, aber gerade nicht verbundenen Server. Dafür gibt es ecm.check_connections(), das pro Server eine eigene Wegwerf-Verbindung aufbaut.

  • Es rührt die geteilte Server-Session nicht an. Ist die serverseitige Session weg (Serverneustart, Session-Timeout, krn.SessionDrop), erkennt das erst der nächste Verbindungsaufbau: krn.SessionAttach liefert dann ein abweichendes SessionGUID, der veraltete Eintrag wird verworfen und ein vollständiger Login durchgeführt. KeepAlive und Session-Recovery sind zwei unabhängige Mechanismen, die sich ergänzen: das KeepAlive entfernt die tote Verbindung, der nächste execute() baut eine neue mit frischer Session auf.

6. Statistik auslesen

for stats in pool.connection_stats():
    print(
        f"{stats.hostname}:{stats.port} "
        f"calls={stats.call_count} "
        f"keepalives={stats.keepalive_count} "
        f"last_keepalive={stats.last_keepalive_at}"
    )

7. Pool sauber beenden

Solange ein KeepAlive läuft, sollte der Pool explizit geschlossen werden. close() (sync) bzw. aclose() (async) stoppt den Worker und schliesst alle Leerlaufverbindungen; beide sind mehrfach aufrufbar.

# Sync
pool = SyncPoolClient(..., keepalive_interval=300)
try:
    ...
finally:
    pool.close()

# oder als Kontextmanager
with SyncPoolClient(..., keepalive_interval=300) as pool:
    ...
# Async
async with AsyncPoolClient(..., keepalive_interval=300) as pool:
    ...

Der Worker hält den Pool nur über eine schwache Referenz. Ein Pool, der ohne close() einfach losgelassen wird, kann daher weiterhin vom Garbage Collector eingesammelt werden — der Worker beendet sich dann von selbst. Das ist die Rückfallebene, nicht der empfohlene Weg: Zeitpunkt und Reihenfolge bestimmt dann der Garbage Collector.

8. FastAPI

Im Lifespan gehört der Pool einmal aufgebaut und beim Herunterfahren explizit geschlossen:

@asynccontextmanager
async def lifespan(app: FastAPI):
    client = AsyncPoolClient(
        servers=os.environ["ECM_SERVERS"],
        username=os.environ["ECM_USERNAME"],
        password=os.environ["ECM_PASSWORD"],
        keepalive_interval=300,
    )
    app.state.ecm = ECM(client)
    yield
    await client.aclose()
    app.state.ecm = None

Wird nur die ECM-Instanz herumgereicht, kommt man über ecm.client an den Pool: await ecm.client.aclose().

Zwei Punkte, die in einem Web-Service leicht übersehen werden:

  • Der asyncio.Task wird beim Erzeugen der ersten Verbindung im laufenden Event Loop angelegt, nicht im Konstruktor. Ein im Lifespan gebauter Pool bindet sich damit immer an den Loop, der ihn auch benutzt.

  • Läuft der Service mit mehreren Worker-Prozessen (uvicorn --workers, Gunicorn), hat jeder Prozess seinen eigenen Pool und damit seinen eigenen KeepAlive-Worker. Das Intervall gilt pro Prozess.

Das vollständige Beispiel steht in FastAPI-Integration.